From 69e310df46900b9edf3f2f20e86b444ba48314c3 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 00:00:40 -0400 Subject: [PATCH 001/422] docs: define parser family architecture --- ...07-29-parser-family-architecture-design.md | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-29-parser-family-architecture-design.md 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..3eb3bad82 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-parser-family-architecture-design.md @@ -0,0 +1,270 @@ +# Parser Family Architecture and Format Roadmap + +## Status + +Approved design. This document defines the public module skeleton, compatibility +policy, and issue boundaries for the next generation of `cmtraceopen-parser`. + +## 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 +│ ├── client [current facade over ccm] +│ ├── site_server [planned] +│ │ ├── management_point +│ │ ├── distribution_point +│ │ └── software_update_point +│ └── co_management [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. `sccm::client` is a +product façade over it. This avoids duplicating the parser while reserving a +truthful home for future Configuration Manager site-server logs. + +### 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. + +## 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. Each new parser 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. + +Initial tracker candidates: + +1. SCCM site-server log-family discovery and contract inventory. +2. Intune Windows Win32 app-install evidence parser. +3. Intune Windows Microsoft Store app evidence parser. +4. Intune Windows platform-script evidence parser. +5. Intune Windows remediation evidence parser. +6. Intune macOS app-management package and shell-script evidence parser. +7. Intune Windows Autopilot evidence parser. +8. Intune Windows configuration evidence parser. +9. Intune Windows compliance evidence parser. +10. Intune Windows Update for Business evidence parser. +11. Company Portal for Windows parser. +12. Company Portal for macOS parser. +13. Company Portal for Android imported-diagnostics parser. +14. Company Portal for iOS/iPadOS imported-diagnostics parser. + +Existing parsers do not receive duplicates: MSI and PSADT are already tracked +by issue #23, while 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. + +## 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. From 517213b3213984c84b17d7a6b7c30759e65fcf8f Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 00:28:29 -0400 Subject: [PATCH 002/422] docs: define SCCM diagnostic roadmap --- ...07-29-parser-family-architecture-design.md | 337 ++++++++++++++++-- 1 file changed, 304 insertions(+), 33 deletions(-) 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 index 3eb3bad82..80588120c 100644 --- a/docs/superpowers/specs/2026-07-29-parser-family-architecture-design.md +++ b/docs/superpowers/specs/2026-07-29-parser-family-architecture-design.md @@ -2,8 +2,15 @@ ## Status -Approved design. This document defines the public module skeleton, compatibility -policy, and issue boundaries for the next generation of `cmtraceopen-parser`. +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 @@ -60,13 +67,41 @@ cmtraceopen_parser │ ├── timestamped │ └── plain │ -├── sccm -│ ├── client [current facade over ccm] -│ ├── site_server [planned] -│ │ ├── management_point -│ │ ├── distribution_point -│ │ └── software_update_point -│ └── co_management [planned] +├── 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 @@ -155,9 +190,15 @@ cmtraceopen_parser ### CCM and SCCM are separate concepts -`ccm` owns the raw CMTrace-compatible record grammar. `sccm::client` is a -product façade over it. This avoids duplicating the parser while reserving a -truthful home for future Configuration Manager site-server logs. +`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 @@ -203,6 +244,141 @@ For at least one minor release: 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. + ## Skeleton PR scope The skeleton PR will: @@ -214,8 +390,8 @@ The skeleton PR will: 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. Each new parser belongs in a -separate PR that closes its own issue. +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 @@ -223,27 +399,115 @@ 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. -Initial tracker candidates: - -1. SCCM site-server log-family discovery and contract inventory. -2. Intune Windows Win32 app-install evidence parser. -3. Intune Windows Microsoft Store app evidence parser. -4. Intune Windows platform-script evidence parser. -5. Intune Windows remediation evidence parser. -6. Intune macOS app-management package and shell-script evidence parser. -7. Intune Windows Autopilot evidence parser. -8. Intune Windows configuration evidence parser. -9. Intune Windows compliance evidence parser. -10. Intune Windows Update for Business evidence parser. -11. Company Portal for Windows parser. -12. Company Portal for macOS parser. -13. Company Portal for Android imported-diagnostics parser. -14. Company Portal for iOS/iPadOS imported-diagnostics parser. +### 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. + +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, while 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. +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 @@ -261,6 +525,13 @@ 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. + ## Non-goals - No new parser implementation is included in the skeleton PR. From df1a2a8fdfc1485bc9870dc6e8b299db8a999b9d Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 01:21:24 -0400 Subject: [PATCH 003/422] docs: add SCCM diagnostic execution gates --- ...07-29-parser-family-architecture-design.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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 index 80588120c..a7a1d8c8c 100644 --- a/docs/superpowers/specs/2026-07-29-parser-family-architecture-design.md +++ b/docs/superpowers/specs/2026-07-29-parser-family-architecture-design.md @@ -379,6 +379,31 @@ 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: @@ -407,6 +432,11 @@ 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). @@ -532,6 +562,12 @@ 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. From f2caccfb8602638807bff5c19d722c037676ca43 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 10:24:24 -0400 Subject: [PATCH 004/422] docs: add SCCM diagnostic implementation plans --- .../plans/2026-07-30-sccm-client-extended.md | 577 ++++++++++++ .../2026-07-30-sccm-client-intake-and-core.md | 808 +++++++++++++++++ .../2026-07-30-sccm-cross-side-correlation.md | 492 +++++++++++ .../plans/2026-07-30-sccm-diagnostic-spine.md | 833 ++++++++++++++++++ .../2026-07-30-sccm-diagnostics-program.md | 178 ++++ .../plans/2026-07-30-sccm-server-extended.md | 454 ++++++++++ .../2026-07-30-sccm-server-intake-and-core.md | 578 ++++++++++++ 7 files changed, 3920 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-sccm-client-extended.md create mode 100644 docs/superpowers/plans/2026-07-30-sccm-client-intake-and-core.md create mode 100644 docs/superpowers/plans/2026-07-30-sccm-cross-side-correlation.md create mode 100644 docs/superpowers/plans/2026-07-30-sccm-diagnostic-spine.md create mode 100644 docs/superpowers/plans/2026-07-30-sccm-diagnostics-program.md create mode 100644 docs/superpowers/plans/2026-07-30-sccm-server-extended.md create mode 100644 docs/superpowers/plans/2026-07-30-sccm-server-intake-and-core.md 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..4dca3df7f --- /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`, `CONTOSO`, 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.log.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.log.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..55ad7dc2e --- /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, CONTOSO, 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": "CONTOSO" + }, + "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. From 51edc4557c165e24474b5995fbe802563d7b3a34 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:11:32 -0400 Subject: [PATCH 005/422] docs(sccm): prepare role-aware server intake corpus (#335) (#337) Reviewed synthetic server-intake preparation slice. Production native collection remains blocked on approved #318 contracts and native Windows validation. --- .../tests/fixtures/sccm/server/README.md | 102 ++++++++ .../server/intake/absent-dp/expected.json | 8 + .../server/intake/absent-dp/manifest.json | 11 + .../intake/access-denied-mp/expected.json | 7 + .../intake/access-denied-mp/manifest.json | 11 + .../current/wsyncmgr.log | 1 + .../server/intake/capped-sup/expected.json | 10 + .../server/intake/capped-sup/manifest.json | 11 + .../root-7d4a9c2e/current/MP_GetPolicy.log | 1 + .../root-b83f10d6/current/MP_GetPolicy.log | 1 + .../expected.json | 20 ++ .../manifest.json | 12 + .../server-mp-policy/current/MP_GetPolicy.log | 1 + .../current/distmgr.log | 1 + .../server-sitecomp/current/sitecomp.log | 1 + .../current/wsyncmgr.log | 1 + .../intake/complete-multi-role/expected.json | 18 ++ .../intake/complete-multi-role/manifest.json | 14 ++ .../server-mp-policy/current/MP_GetPolicy.log | 1 + .../configured-nondefault-path/expected.json | 9 + .../configured-nondefault-path/manifest.json | 11 + .../server-mp-policy/current/MP_GetPolicy.log | 2 + .../server/intake/multiline/expected.json | 7 + .../server/intake/multiline/manifest.json | 11 + .../server-mp-policy/current/MP_GetPolicy.log | 1 + .../server-mp-policy/lo_/MP_GetPolicy.log.lo_ | 1 + .../numbered-2/MP_GetPolicy.log.2 | 1 + .../MP_GetPolicy.log.20260729-235700 | 1 + .../server/intake/rotations/expected.json | 16 ++ .../server/intake/rotations/manifest.json | 14 ++ .../server/intake/skipped-iis/expected.json | 7 + .../server/intake/skipped-iis/manifest.json | 11 + .../server-mp-policy/current/MP_GetPolicy.log | 1 + .../server-sitecomp/current/sitecomp.log | 1 + .../server-status/current/statmgr.log | 1 + .../intake/unsorted-manifest/expected.json | 19 ++ .../intake/unsorted-manifest/manifest.json | 14 ++ .../unsupported-db-supplement/expected.json | 8 + .../unsupported-db-supplement/manifest.json | 11 + .../preparation/issue-335-server-intake.md | 230 ++++++++++++++++++ 40 files changed, 609 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/README.md create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/absent-dp/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/absent-dp/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/access-denied-mp/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/access-denied-mp/manifest.json create mode 100644 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 create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/capped-sup/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/capped-sup/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/collision-same-basename-configured-roots/evidence/sccm/server/management-point/server-mp-policy/root-7d4a9c2e/current/MP_GetPolicy.log create mode 100644 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 create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/collision-same-basename-configured-roots/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/collision-same-basename-configured-roots/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log create mode 100644 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 create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log create mode 100644 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 create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/configured-nondefault-path/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/configured-nondefault-path/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/configured-nondefault-path/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/lo_/MP_GetPolicy.log.lo_ create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/numbered-2/MP_GetPolicy.log.2 create mode 100644 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 create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/skipped-iis/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/skipped-iis/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/evidence/sccm/server/management-point/server-mp-policy/current/MP_GetPolicy.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/evidence/sccm/server/site-server/server-status/current/statmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsupported-db-supplement/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsupported-db-supplement/manifest.json create mode 100644 docs/sccm/preparation/issue-335-server-intake.md 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..b45b676ea --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/README.md @@ -0,0 +1,102 @@ +# Synthetic SCCM server intake fixtures + +These fixtures prepare issue #335 while #318 owns the shared SCCM schema. They +are intentionally not connected to a Rust test target yet. Every value is +synthetic, deterministic, and privacy-safe: + +- permitted topology labels are `LAB-CM01`, `LAB-MP01`, `LAB-DP01`, and + `CONTOSO`; +- 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/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..eb6b2b158 --- /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": "CONTOSO", "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..5b5bea22f --- /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": "CONTOSO", "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..c1653dc3f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/collision-same-basename-configured-roots/expected.json @@ -0,0 +1,20 @@ +{ + "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, + "destinationsPrecomputed": true, + "atomicCreateNoOverwrite": true, + "neitherOverwritten": true, + "notMerged": true, + "normalizedArtifactCount": 2, + "exactReferencesResolve": true + }, + "artifactProvenance": [ + { "artifactId": "mp-policy-root-a-current", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false, "bytesCopied": 173 }, + { "artifactId": "mp-policy-root-b-current", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false, "bytesCopied": 172 } + ] +} 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..2bbf6cbcc --- /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": "CONTOSO", "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..268f6e673 --- /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": "CONTOSO", "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..58b4a187e --- /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": "CONTOSO", "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..1bae87c0a --- /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": "CONTOSO", "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.log.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/lo_/MP_GetPolicy.log.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.log.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..2961af624 --- /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": "CONTOSO", "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.log.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.log.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..e206619f2 --- /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": "CONTOSO", "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/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..fa8f3f9f5 --- /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": "pending #318 deterministic evidence contract", + "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..12d37be4e --- /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": "CONTOSO", "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..998d66d2c --- /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": "CONTOSO", "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/docs/sccm/preparation/issue-335-server-intake.md b/docs/sccm/preparation/issue-335-server-intake.md new file mode 100644 index 000000000..cd6b66fea --- /dev/null +++ b/docs/sccm/preparation/issue-335-server-intake.md @@ -0,0 +1,230 @@ +# 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 + +- 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 `.log.lo_`, `.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. From 59c8aba68214736f355517d49e5e9cc11c6182e1 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:11:39 -0400 Subject: [PATCH 006/422] docs(sccm): prepare deterministic client intake corpus (#319) (#339) Reviewed synthetic client-intake preparation slice. Production native collection remains blocked on approved #318 contracts and native Windows validation. --- .../tests/fixtures/sccm/client/README.md | 52 ++++ .../client-policy-state/current/CIAgent.log | 1 + .../client/intake/access-denied/expected.json | 9 + .../client/intake/access-denied/manifest.json | 10 + .../current/DataTransferService.log | 1 + .../sccm/client/intake/capped/expected.json | 13 + .../sccm/client/intake/capped/manifest.json | 9 + .../root-a/current/AppEnforce.log | 1 + .../root-b/current/AppEnforce.log | 1 + .../client/intake/collision/expected.json | 46 ++++ .../client/intake/collision/manifest.json | 54 +++++ .../client-app-enforce/current/AppEnforce.log | 1 + .../current/AppIntentEval.log | 1 + .../client-ccmsetup/current/ccmsetup.log | 1 + .../evidence/client-content/current/CAS.log | 1 + .../client-evaluation/current/CcmEval.log | 1 + .../current/ClientIDManagerStartup.log | 1 + .../current/LocationServices.log | 1 + .../current/PolicyAgent.log | 1 + .../client-policy-state/current/CIAgent.log | 1 + .../client-updates/current/ScanAgent.log | 1 + .../current/ReportingEvents.log | 1 + .../sccm/client/intake/complete/expected.json | 40 ++++ .../sccm/client/intake/complete/manifest.json | 25 ++ .../client/intake/missing-root/expected.json | 10 + .../client/intake/missing-root/manifest.json | 19 ++ .../client-app-enforce/current/AppEnforce.log | 1 + .../client-app-enforce/lo/AppEnforce.log.lo_ | 1 + .../numbered-2/AppEnforce.log.2 | 1 + .../client/intake/rotations/expected.json | 13 + .../client/intake/rotations/manifest.json | 11 + .../sccm_client_intake_fixture_contract.rs | 26 ++ .../preparation/issue-319-client-intake.md | 223 ++++++++++++++++++ 33 files changed, 578 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/access-denied/evidence/client-policy-state/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/access-denied/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/access-denied/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/evidence/client-content/current/DataTransferService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/evidence/client-app-enforce/root-a/current/AppEnforce.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/evidence/client-app-enforce/root-b/current/AppEnforce.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-app-enforce/current/AppEnforce.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-app-intent/current/AppIntentEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-ccmsetup/current/ccmsetup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-content/current/CAS.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-evaluation/current/CcmEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-identity/current/ClientIDManagerStartup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-location-services-shared/current/LocationServices.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-policy-state/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-windows-update-supplemental/current/ReportingEvents.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/missing-root/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/missing-root/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/current/AppEnforce.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/lo/AppEnforce.log.lo_ create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/numbered-2/AppEnforce.log.2 create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_intake_fixture_contract.rs create mode 100644 docs/sccm/preparation/issue-319-client-intake.md 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..f0467b0b0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md @@ -0,0 +1,52 @@ +# Synthetic SCCM client intake fixtures + +These are preparation-only fixtures for issue #319. They are not accepted by a +production SCCM reader until #318 publishes its stable public contracts. +`manifest.json` and `expected.json` are proposed contract inputs/outputs, not +compiled test fixtures. Every identity, path, timestamp, byte count, UUID, and +log record is deterministic and synthetic. + +Privacy markers: manifests require `syntheticFixture: true` and +`proposalOnly: true`; evidence files use only `LAB-CLIENT-01`, `CONTOSO`, fake +package/content IDs, or RFC-style test UUIDs; `SYNTHETIC://` is opaque fixture +provenance. 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 test designs in +`docs/sccm/preparation/issue-319-client-intake.md`, pending #318 contracts. + +Expected arrays are stable-sorted by `logicalArtifactId`. `contractState` must +remain `proposedPending318` until an implementation maps this design to the +published spine schema. Replay after #318: deserialize via its public reader, +assert coverage directly, reorder artifacts and compare normalized output, and +never interpret 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 +`artifactProvenance`. Noncapture artifacts remain `bytesCopied: 0` with a null +relative path 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/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..c270457e5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/access-denied/expected.json @@ -0,0 +1,9 @@ +{ + "contractState": "proposedPending318", + "scenario": "access-denied", + "workflowDiagnosisExpected": false, + "coverage": [{"logicalArtifactId":"client-policy-agent","state":"accessDenied"},{"logicalArtifactId":"client-policy-state","state":"captured"}], + "artifactProvenance": [{"artifactId":"fixture-access-policy-state-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":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..9e450ea63 --- /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":"CONTOSO","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..3a6015dfe --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/expected.json @@ -0,0 +1,46 @@ +{ + "contractState": "proposedPending318", + "scenario": "collision", + "workflowDiagnosisExpected": false, + "coverage": [ + { + "logicalArtifactId": "client-app-enforce", + "state": "captured", + "physicalArtifactCount": 2 + } + ], + "artifactProvenance": [ + { + "artifactId": "fixture-collision-app-enforce-root-a-current", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + }, + { + "artifactId": "fixture-collision-app-enforce-root-b-current", + "encoding": "utf-8", + "byteLimit": 4096, + "limitApplied": false + } + ], + "preservedPhysicalArtifacts": [ + { + "artifactId": "fixture-collision-app-enforce-root-a-current", + "pathFingerprint": "synthetic-collision-root-a", + "relativePath": "evidence/client-app-enforce/root-a/current/AppEnforce.log" + }, + { + "artifactId": "fixture-collision-app-enforce-root-b-current", + "pathFingerprint": "synthetic-collision-root-b", + "relativePath": "evidence/client-app-enforce/root-b/current/AppEnforce.log" + } + ], + "assertions": { + "distinctArtifactIds": true, + "distinctPathFingerprints": true, + "distinctRelativePaths": true, + "mergedByBasename": false, + "overwrittenByBasename": false + }, + "requests": [] +} 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..2bf5b8044 --- /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": "CONTOSO", + "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-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-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..edaecb1ac --- /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-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-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..29d6a8141 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json @@ -0,0 +1,40 @@ +{ + "contractState": "proposedPending318", + "scenario": "complete", + "workflowDiagnosisExpected": false, + "coverage": [ + {"logicalArtifactId":"client-app-enforce","state":"captured"}, + {"logicalArtifactId":"client-app-intent","state":"captured"}, + {"logicalArtifactId":"client-ccmsetup","state":"captured"}, + {"logicalArtifactId":"client-content","state":"captured"}, + {"logicalArtifactId":"client-evaluation","state":"captured"}, + {"logicalArtifactId":"client-identity","state":"captured"}, + {"logicalArtifactId":"client-location","state":"captured"}, + {"logicalArtifactId":"client-policy-agent","state":"captured"}, + {"logicalArtifactId":"client-policy-state","state":"captured"}, + {"logicalArtifactId":"client-updates","state":"captured"}, + {"logicalArtifactId":"client-windows-update-supplemental","state":"captured"} + ], + "artifactProvenance": [ + {"artifactId":"fixture-complete-app-enforce-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"fixture-complete-app-intent-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"fixture-complete-ccmsetup-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"fixture-complete-content-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"fixture-complete-evaluation-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"fixture-complete-identity-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"fixture-complete-location-services-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"fixture-complete-policy-agent-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"fixture-complete-policy-state-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"fixture-complete-updates-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"fixture-complete-update-supplemental-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "physicalArtifactAssertions": [ + { + "artifactId": "fixture-complete-location-services-root-a-current", + "catalogEntryId": "client-location-services-shared", + "groupMemberships": ["client-content", "client-location"], + "physicalCaptureCount": 1 + } + ], + "requests": [] +} 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..26ca1a181 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/manifest.json @@ -0,0 +1,25 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "bundle": { + "role": "client", + "captureHost": "LAB-CLIENT-01", + "siteCode": "CONTOSO", + "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":172,"relativePath":"evidence/client-location-services-shared/current/LocationServices.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-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-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"} + ] +} 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..27bb11d24 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/missing-root/expected.json @@ -0,0 +1,10 @@ +{ + "contractState": "proposedPending318", + "scenario": "missing-root", + "workflowDiagnosisExpected": false, + "coverage": [ + {"logicalArtifactId":"client-app-enforce","state":"absent"},{"logicalArtifactId":"client-app-intent","state":"absent"},{"logicalArtifactId":"client-ccmsetup","state":"absent"},{"logicalArtifactId":"client-content","state":"absent"},{"logicalArtifactId":"client-evaluation","state":"absent"},{"logicalArtifactId":"client-identity","state":"absent"},{"logicalArtifactId":"client-location","state":"absent"},{"logicalArtifactId":"client-policy-agent","state":"absent"},{"logicalArtifactId":"client-policy-state","state":"absent"},{"logicalArtifactId":"client-updates","state":"absent"},{"logicalArtifactId":"client-windows-update-supplemental","state":"absent"} + ], + "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..fa81e77f8 --- /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":"CONTOSO","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.log.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/lo/AppEnforce.log.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.log.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..008b072db --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json @@ -0,0 +1,13 @@ +{ + "contractState": "proposedPending318", + "scenario": "rotations", + "workflowDiagnosisExpected": false, + "coverage": [{"logicalArtifactId":"client-app-enforce","state":"captured","fragmentCount":3,"rotationOrder":["current","lo","numbered:2"],"distinctPathFingerprints":3}], + "artifactProvenance": [ + {"artifactId":"fixture-rotations-app-enforce-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"fixture-rotations-app-enforce-root-a-lo","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"fixture-rotations-app-enforce-root-b-numbered-2","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "requests": [], + "assertions": ["Each rotation fragment retains its physical artifact ID and path fingerprint.", "No phase, key, or finding is inferred from rotation grouping alone."] +} 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..78cfd31f9 --- /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":"CONTOSO","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-current","rotation":{"kind":"current","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.log.lo_","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.log.lo_","pathFingerprint":"synthetic-root-a-lo","rotation":{"kind":"lo","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:01:01Z","bytesCopied":176,"relativePath":"evidence/client-app-enforce/lo/AppEnforce.log.lo_"}, + {"artifactId":"fixture-rotations-app-enforce-root-b-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-b/CCM/Logs/AppEnforce.log.2","pathFingerprint":"synthetic-root-b-numbered-2","rotation":{"kind":"numbered","number":2,"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/sccm_client_intake_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake_fixture_contract.rs new file mode 100644 index 000000000..df5df66ba --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake_fixture_contract.rs @@ -0,0 +1,26 @@ +use cmtraceopen_parser::{ + models::log_entry::LogFormat, + parser::{parse_content_with_selection, ResolvedParser}, +}; + +const CAPPED_CONTENT: &[u8] = include_bytes!( + "fixtures/sccm/client/intake/capped/evidence/client-content/current/DataTransferService.log" +); + +#[test] +fn capped_client_content_is_an_exact_incomplete_ccm_prefix() { + assert_eq!(CAPPED_CONTENT.len(), 128); + + let content = std::str::from_utf8(CAPPED_CONTENT).expect("fixture is declared UTF-8"); + assert!(content.starts_with(" Date: Thu, 30 Jul 2026 16:11:48 -0400 Subject: [PATCH 007/422] docs(sccm): prepare site-core workflow corpus (#327) (#338) Reviewed site-core/status synthetic workflow corpus. Production reducer remains blocked on approved #318/#335 interfaces; no native Windows acceptance is claimed. --- .../site-core/sitecomp/current/sitecomp.log | 3 + .../site_core/component-failure/expected.json | 82 ++++++ .../site_core/component-failure/manifest.json | 53 ++++ .../site-core/sitecomp/current/sitecomp.log | 6 + .../site-core/status/current/statmgr.log | 2 + .../site_core/contradictory/expected.json | 129 ++++++++++ .../site_core/contradictory/manifest.json | 53 ++++ .../site-core/sitecomp/current/sitecomp.log | 3 + .../site-core/status/current/statmgr.log | 2 + .../server/site_core/healthy/expected.json | 85 ++++++ .../server/site_core/healthy/manifest.json | 53 ++++ .../site-core/sitecomp/current/sitecomp.log | 3 + .../site_core/inbox-backlog/expected.json | 100 ++++++++ .../site_core/inbox-backlog/manifest.json | 53 ++++ .../site-core/sitecomp/current/sitecomp.log | 3 + .../server/site_core/incomplete/expected.json | 112 ++++++++ .../server/site_core/incomplete/manifest.json | 74 ++++++ .../site-core/status/current/statmgr.log | 1 + .../server/site_core/malformed/expected.json | 93 +++++++ .../server/site_core/malformed/manifest.json | 36 +++ .../site-core/sitecomp/current/sitecomp.log | 3 + .../site-core/status/current/statmgr.log | 2 + .../server/site_core/recovery/expected.json | 88 +++++++ .../server/site_core/recovery/manifest.json | 53 ++++ .../site-core/sitecomp/current/sitecomp.log | 1 + .../site-core/sitecomp/lo_/sitecomp.log.lo_ | 1 + .../site_core/rotation-boundary/expected.json | 113 ++++++++ .../site_core/rotation-boundary/manifest.json | 57 +++++ .../site-core/sitecomp/current/sitecomp.log | 3 + .../site-core/status/current/statmgr.log | 2 + .../status-processing-failure/expected.json | 86 +++++++ .../status-processing-failure/manifest.json | 53 ++++ .../issue-327-server-site-core-corpus.md | 241 ++++++++++++++++++ 33 files changed, 1649 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/status/current/statmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/status/current/statmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/evidence/sccm/server/site-core/status/current/statmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-core/status/current/statmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/lo_/sitecomp.log.lo_ create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-core/status/current/statmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/manifest.json create mode 100644 docs/sccm/preparation/issue-327-server-site-core-corpus.md diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-core/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-core/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..9ae3b814a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/expected.json @@ -0,0 +1,82 @@ +{ + "expectedContractVersion": 1, + "scenario": "component-failure", + "profile": { + "id": "sccm-site-core", + "version": 1, + "stability": "experimental" + }, + "results": [ + { + "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-COMPFAIL-001", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteCode": "LAB", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-COMPFAIL-001" + }, + "state": "terminalFailure", + "lastSuccessfulPhase": "componentWork", + "findingClass": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "evidence": [ + { + "artifactId": "component-failure-sitecomp-current", + "entryId": "component-failure-sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "component-failure-sitecomp-current", + "entryId": "component-failure-sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "component-failure-sitecomp-current", + "entryId": "component-failure-sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3, + "terminal": true + } + ], + "coverageGapArtifactIds": [ + "component-failure-statmgr-absent" + ], + "nextArtifacts": [] + } + ], + "unlinkedObservations": [], + "coverageGaps": [ + { + "artifactId": "component-failure-statmgr-absent", + "state": "absent", + "diagnosticMeaning": "coverageOnly" + } + ], + "prohibitedClaims": [ + "absentDownstreamRole", + "clientImpact", + "crossSideCausality" + ], + "ordering": { + "resultsBy": [ + "resultId" + ], + "evidenceBy": [ + "artifactId", + "lineStart", + "lineEnd" + ], + "coverageGapsBy": [ + "artifactId" + ], + "nextArtifactsBy": [ + "logicalName", + "role", + "reasonCode" + ] + } +} 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..dcf518d11 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/manifest.json @@ -0,0 +1,53 @@ +{ + "sccmManifestVersion": 1, + "bundleRole": "server", + "syntheticFixture": true, + "scenario": "component-failure", + "topology": { + "captureHost": "LAB-CM01", + "rolesObserved": [ + "siteServer" + ], + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "component-failure-sitecomp-current", + "role": "siteServer", + "sourceGroup": "server-sitecomp", + "sourceKind": "ccmLog", + "originalPath": "REDACTED", + "originalBasename": "sitecomp.log", + "configuredPath": true, + "rotation": { + "kind": "current" + }, + "rotationLineage": "sitecomp.log", + "captureState": "captured", + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T14:11:00Z", + "encoding": "utf-8", + "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "bytesCopied": 1000 + }, + { + "artifactId": "component-failure-statmgr-absent", + "role": "siteServer", + "sourceGroup": "server-status", + "sourceKind": "ccmLog", + "originalPath": "REDACTED", + "originalBasename": "statmgr.log", + "configuredPath": false, + "rotation": { + "kind": "current" + }, + "rotationLineage": "statmgr.log", + "captureState": "absent", + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T14:11:00Z", + "encoding": null, + "relativePath": null, + "bytesCopied": 0 + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/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-core/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-core/status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/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-core/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..9d26c212c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/expected.json @@ -0,0 +1,129 @@ +{ + "expectedContractVersion": 1, + "scenario": "contradictory", + "profile": { + "id": "sccm-site-core", + "version": 1, + "stability": "experimental" + }, + "results": [ + { + "resultId": "site-core/LAB/SMS_DISTRIBUTION_MANAGER/SC-CONTRA-DIST-001", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteCode": "LAB", + "componentId": "SMS_DISTRIBUTION_MANAGER", + "workItemId": "SC-CONTRA-DIST-001" + }, + "state": "healthy", + "lastSuccessfulPhase": "healthyOrTerminal", + "findingClass": null, + "confidence": "high", + "confidenceCeiling": "high", + "evidence": [ + { + "artifactId": "contradictory-sitecomp-current", + "entryId": "contradictory-sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "contradictory-sitecomp-current", + "entryId": "contradictory-sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + }, + { + "artifactId": "contradictory-sitecomp-current", + "entryId": "contradictory-sitecomp-current:5-5", + "lineStart": 5, + "lineEnd": 5 + }, + { + "artifactId": "contradictory-statmgr-current", + "entryId": "contradictory-statmgr-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "contradictory-statmgr-current", + "entryId": "contradictory-statmgr-current:2-2", + "lineStart": 2, + "lineEnd": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifacts": [] + }, + { + "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-CONTRA-EXEC-001", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteCode": "LAB", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-CONTRA-EXEC-001" + }, + "state": "terminalFailure", + "lastSuccessfulPhase": "componentWork", + "findingClass": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "evidence": [ + { + "artifactId": "contradictory-sitecomp-current", + "entryId": "contradictory-sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "contradictory-sitecomp-current", + "entryId": "contradictory-sitecomp-current:4-4", + "lineStart": 4, + "lineEnd": 4 + }, + { + "artifactId": "contradictory-sitecomp-current", + "entryId": "contradictory-sitecomp-current:6-6", + "lineStart": 6, + "lineEnd": 6, + "terminal": true + } + ], + "coverageGapArtifactIds": [], + "nextArtifacts": [] + } + ], + "unlinkedObservations": [], + "coverageGaps": [], + "adversarialAssertions": { + "resultCount": 2, + "sameMinuteMayMerge": false, + "crossComponentRecovery": false, + "timeOnlyCausalClaim": false + }, + "prohibitedClaims": [ + "absentDownstreamRole", + "clientImpact", + "crossSideCausality" + ], + "ordering": { + "resultsBy": [ + "resultId" + ], + "evidenceBy": [ + "artifactId", + "lineStart", + "lineEnd" + ], + "coverageGapsBy": [ + "artifactId" + ], + "nextArtifactsBy": [ + "logicalName", + "role", + "reasonCode" + ] + } +} 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..3d8f5dadd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/manifest.json @@ -0,0 +1,53 @@ +{ + "sccmManifestVersion": 1, + "bundleRole": "server", + "syntheticFixture": true, + "scenario": "contradictory", + "topology": { + "captureHost": "LAB-CM01", + "rolesObserved": [ + "siteServer" + ], + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "contradictory-sitecomp-current", + "role": "siteServer", + "sourceGroup": "server-sitecomp", + "sourceKind": "ccmLog", + "originalPath": "REDACTED", + "originalBasename": "sitecomp.log", + "configuredPath": true, + "rotation": { + "kind": "current" + }, + "rotationLineage": "sitecomp.log", + "captureState": "captured", + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T14:51:00Z", + "encoding": "utf-8", + "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "bytesCopied": 2003 + }, + { + "artifactId": "contradictory-statmgr-current", + "role": "siteServer", + "sourceGroup": "server-status", + "sourceKind": "ccmLog", + "originalPath": "REDACTED", + "originalBasename": "statmgr.log", + "configuredPath": true, + "rotation": { + "kind": "current" + }, + "rotationLineage": "statmgr.log", + "captureState": "captured", + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T14:51:00Z", + "encoding": "utf-8", + "relativePath": "evidence/sccm/server/site-core/status/current/statmgr.log", + "bytesCopied": 685 + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/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-core/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-core/status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/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-core/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..7a2fa2ec5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/expected.json @@ -0,0 +1,85 @@ +{ + "expectedContractVersion": 1, + "scenario": "healthy", + "profile": { + "id": "sccm-site-core", + "version": 1, + "stability": "experimental" + }, + "results": [ + { + "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-HEALTH-001", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteCode": "LAB", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-HEALTH-001" + }, + "state": "healthy", + "lastSuccessfulPhase": "healthyOrTerminal", + "findingClass": null, + "confidence": "high", + "confidenceCeiling": "high", + "evidence": [ + { + "artifactId": "healthy-sitecomp-current", + "entryId": "healthy-sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "healthy-sitecomp-current", + "entryId": "healthy-sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "healthy-sitecomp-current", + "entryId": "healthy-sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + }, + { + "artifactId": "healthy-statmgr-current", + "entryId": "healthy-statmgr-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "healthy-statmgr-current", + "entryId": "healthy-statmgr-current:2-2", + "lineStart": 2, + "lineEnd": 2 + } + ], + "coverageGapArtifactIds": [], + "nextArtifacts": [] + } + ], + "unlinkedObservations": [], + "coverageGaps": [], + "prohibitedClaims": [ + "absentDownstreamRole", + "clientImpact", + "crossSideCausality" + ], + "ordering": { + "resultsBy": [ + "resultId" + ], + "evidenceBy": [ + "artifactId", + "lineStart", + "lineEnd" + ], + "coverageGapsBy": [ + "artifactId" + ], + "nextArtifactsBy": [ + "logicalName", + "role", + "reasonCode" + ] + } +} 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..4e9380969 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/manifest.json @@ -0,0 +1,53 @@ +{ + "sccmManifestVersion": 1, + "bundleRole": "server", + "syntheticFixture": true, + "scenario": "healthy", + "topology": { + "captureHost": "LAB-CM01", + "rolesObserved": [ + "siteServer" + ], + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "healthy-sitecomp-current", + "role": "siteServer", + "sourceGroup": "server-sitecomp", + "sourceKind": "ccmLog", + "originalPath": "REDACTED", + "originalBasename": "sitecomp.log", + "configuredPath": true, + "rotation": { + "kind": "current" + }, + "rotationLineage": "sitecomp.log", + "captureState": "captured", + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T14:01:00Z", + "encoding": "utf-8", + "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "bytesCopied": 983 + }, + { + "artifactId": "healthy-statmgr-current", + "role": "siteServer", + "sourceGroup": "server-status", + "sourceKind": "ccmLog", + "originalPath": "REDACTED", + "originalBasename": "statmgr.log", + "configuredPath": true, + "rotation": { + "kind": "current" + }, + "rotationLineage": "statmgr.log", + "captureState": "captured", + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T14:01:00Z", + "encoding": "utf-8", + "relativePath": "evidence/sccm/server/site-core/status/current/statmgr.log", + "bytesCopied": 653 + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-core/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-core/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..5c88ecdb6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/expected.json @@ -0,0 +1,100 @@ +{ + "expectedContractVersion": 1, + "scenario": "inbox-backlog", + "profile": { + "id": "sccm-site-core", + "version": 1, + "stability": "experimental" + }, + "results": [ + { + "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-BACKLOG-001", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteCode": "LAB", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-BACKLOG-001" + }, + "state": "blockedOrDeferred", + "lastSuccessfulPhase": "componentWork", + "findingClass": "blockedOrDeferred", + "confidence": "low", + "confidenceCeiling": "low", + "evidence": [ + { + "artifactId": "inbox-backlog-sitecomp-current", + "entryId": "inbox-backlog-sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "inbox-backlog-sitecomp-current", + "entryId": "inbox-backlog-sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "inbox-backlog-sitecomp-current", + "entryId": "inbox-backlog-sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3, + "terminal": false + } + ], + "coverageGapArtifactIds": [ + "inbox-backlog-statmgr-absent" + ], + "nextArtifacts": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "matching-status-terminal-evidence-missing", + "basenames": [ + "statmgr.log" + ], + "rotations": [ + "current", + "loUnderscore" + ], + "maxArtifacts": 2, + "scope": { + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-BACKLOG-001" + } + } + ] + } + ], + "unlinkedObservations": [], + "coverageGaps": [ + { + "artifactId": "inbox-backlog-statmgr-absent", + "state": "absent", + "diagnosticMeaning": "coverageOnly" + } + ], + "prohibitedClaims": [ + "absentDownstreamRole", + "clientImpact", + "crossSideCausality" + ], + "ordering": { + "resultsBy": [ + "resultId" + ], + "evidenceBy": [ + "artifactId", + "lineStart", + "lineEnd" + ], + "coverageGapsBy": [ + "artifactId" + ], + "nextArtifactsBy": [ + "logicalName", + "role", + "reasonCode" + ] + } +} 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..c7e4e20be --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/manifest.json @@ -0,0 +1,53 @@ +{ + "sccmManifestVersion": 1, + "bundleRole": "server", + "syntheticFixture": true, + "scenario": "inbox-backlog", + "topology": { + "captureHost": "LAB-CM01", + "rolesObserved": [ + "siteServer" + ], + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "inbox-backlog-sitecomp-current", + "role": "siteServer", + "sourceGroup": "server-sitecomp", + "sourceKind": "ccmLog", + "originalPath": "REDACTED", + "originalBasename": "sitecomp.log", + "configuredPath": true, + "rotation": { + "kind": "current" + }, + "rotationLineage": "sitecomp.log", + "captureState": "captured", + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T14:21:00Z", + "encoding": "utf-8", + "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "bytesCopied": 1000 + }, + { + "artifactId": "inbox-backlog-statmgr-absent", + "role": "siteServer", + "sourceGroup": "server-status", + "sourceKind": "ccmLog", + "originalPath": "REDACTED", + "originalBasename": "statmgr.log", + "configuredPath": false, + "rotation": { + "kind": "current" + }, + "rotationLineage": "statmgr.log", + "captureState": "absent", + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T14:21:00Z", + "encoding": null, + "relativePath": null, + "bytesCopied": 0 + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/evidence/sccm/server/site-core/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-core/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-core/status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-core/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-core/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..26d76c4c1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/expected.json @@ -0,0 +1,88 @@ +{ + "expectedContractVersion": 1, + "scenario": "recovery", + "profile": { + "id": "sccm-site-core", + "version": 1, + "stability": "experimental" + }, + "results": [ + { + "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-RECOVER-001", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteCode": "LAB", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-RECOVER-001" + }, + "state": "recovered", + "lastSuccessfulPhase": "healthyOrTerminal", + "findingClass": "symptom", + "confidence": "high", + "confidenceCeiling": "high", + "evidence": [ + { + "artifactId": "recovery-sitecomp-current", + "entryId": "recovery-sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "recovery-sitecomp-current", + "entryId": "recovery-sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "recovery-sitecomp-current", + "entryId": "recovery-sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3, + "terminal": true + }, + { + "artifactId": "recovery-statmgr-current", + "entryId": "recovery-statmgr-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "recovery-statmgr-current", + "entryId": "recovery-statmgr-current:2-2", + "lineStart": 2, + "lineEnd": 2, + "terminal": true, + "recovery": true + } + ], + "coverageGapArtifactIds": [], + "nextArtifacts": [] + } + ], + "unlinkedObservations": [], + "coverageGaps": [], + "prohibitedClaims": [ + "absentDownstreamRole", + "clientImpact", + "crossSideCausality" + ], + "ordering": { + "resultsBy": [ + "resultId" + ], + "evidenceBy": [ + "artifactId", + "lineStart", + "lineEnd" + ], + "coverageGapsBy": [ + "artifactId" + ], + "nextArtifactsBy": [ + "logicalName", + "role", + "reasonCode" + ] + } +} 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..712a2edbc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/manifest.json @@ -0,0 +1,53 @@ +{ + "sccmManifestVersion": 1, + "bundleRole": "server", + "syntheticFixture": true, + "scenario": "recovery", + "topology": { + "captureHost": "LAB-CM01", + "rolesObserved": [ + "siteServer" + ], + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "recovery-sitecomp-current", + "role": "siteServer", + "sourceGroup": "server-sitecomp", + "sourceKind": "ccmLog", + "originalPath": "REDACTED", + "originalBasename": "sitecomp.log", + "configuredPath": true, + "rotation": { + "kind": "current" + }, + "rotationLineage": "sitecomp.log", + "captureState": "captured", + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T14:43:00Z", + "encoding": "utf-8", + "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "bytesCopied": 997 + }, + { + "artifactId": "recovery-statmgr-current", + "role": "siteServer", + "sourceGroup": "server-status", + "sourceKind": "ccmLog", + "originalPath": "REDACTED", + "originalBasename": "statmgr.log", + "configuredPath": true, + "rotation": { + "kind": "current" + }, + "rotationLineage": "statmgr.log", + "captureState": "captured", + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T14:43:00Z", + "encoding": "utf-8", + "relativePath": "evidence/sccm/server/site-core/status/current/statmgr.log", + "bytesCopied": 657 + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/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-core/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-core/sitecomp/lo_/sitecomp.log.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/lo_/sitecomp.log.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-core/sitecomp/lo_/sitecomp.log.lo_ @@ -0,0 +1 @@ + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-core/status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-core/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-core/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..b7ef1017c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/expected.json @@ -0,0 +1,86 @@ +{ + "expectedContractVersion": 1, + "scenario": "status-processing-failure", + "profile": { + "id": "sccm-site-core", + "version": 1, + "stability": "experimental" + }, + "results": [ + { + "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-STATUSFAIL-001", + "transactionKey": { + "profileId": "sccm-site-core", + "profileVersion": 1, + "siteCode": "LAB", + "componentId": "SMS_EXECUTIVE", + "workItemId": "SC-STATUSFAIL-001" + }, + "state": "terminalFailure", + "lastSuccessfulPhase": "statusOrStateProcessing", + "findingClass": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "evidence": [ + { + "artifactId": "status-processing-failure-sitecomp-current", + "entryId": "status-processing-failure-sitecomp-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "status-processing-failure-sitecomp-current", + "entryId": "status-processing-failure-sitecomp-current:2-2", + "lineStart": 2, + "lineEnd": 2 + }, + { + "artifactId": "status-processing-failure-sitecomp-current", + "entryId": "status-processing-failure-sitecomp-current:3-3", + "lineStart": 3, + "lineEnd": 3 + }, + { + "artifactId": "status-processing-failure-statmgr-current", + "entryId": "status-processing-failure-statmgr-current:1-1", + "lineStart": 1, + "lineEnd": 1 + }, + { + "artifactId": "status-processing-failure-statmgr-current", + "entryId": "status-processing-failure-statmgr-current:2-2", + "lineStart": 2, + "lineEnd": 2, + "terminal": true + } + ], + "coverageGapArtifactIds": [], + "nextArtifacts": [] + } + ], + "unlinkedObservations": [], + "coverageGaps": [], + "prohibitedClaims": [ + "absentDownstreamRole", + "clientImpact", + "crossSideCausality" + ], + "ordering": { + "resultsBy": [ + "resultId" + ], + "evidenceBy": [ + "artifactId", + "lineStart", + "lineEnd" + ], + "coverageGapsBy": [ + "artifactId" + ], + "nextArtifactsBy": [ + "logicalName", + "role", + "reasonCode" + ] + } +} 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..4e9e2a109 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/manifest.json @@ -0,0 +1,53 @@ +{ + "sccmManifestVersion": 1, + "bundleRole": "server", + "syntheticFixture": true, + "scenario": "status-processing-failure", + "topology": { + "captureHost": "LAB-CM01", + "rolesObserved": [ + "siteServer" + ], + "siteCode": "LAB" + }, + "artifacts": [ + { + "artifactId": "status-processing-failure-sitecomp-current", + "role": "siteServer", + "sourceGroup": "server-sitecomp", + "sourceKind": "ccmLog", + "originalPath": "REDACTED", + "originalBasename": "sitecomp.log", + "configuredPath": true, + "rotation": { + "kind": "current" + }, + "rotationLineage": "sitecomp.log", + "captureState": "captured", + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T14:31:00Z", + "encoding": "utf-8", + "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "bytesCopied": 995 + }, + { + "artifactId": "status-processing-failure-statmgr-current", + "role": "siteServer", + "sourceGroup": "server-status", + "sourceKind": "ccmLog", + "originalPath": "REDACTED", + "originalBasename": "statmgr.log", + "configuredPath": true, + "rotation": { + "kind": "current" + }, + "rotationLineage": "statmgr.log", + "captureState": "captured", + "sourceVersion": "5.00.TEST", + "collectedUtc": "2026-07-30T14:31:00Z", + "encoding": "utf-8", + "relativePath": "evidence/sccm/server/site-core/status/current/statmgr.log", + "bytesCopied": 667 + } + ] +} 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. From 3c2ac366d0a0a2dc9d4bb8c15f45e75f70c23106 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:12:01 -0400 Subject: [PATCH 008/422] docs(sccm): harden client health workflow corpus (#320) (#340) Reviewed client health/location synthetic workflow corpus. Production reducer remains blocked on approved #318/#319 interfaces; no native Windows acceptance is claimed. --- .../client-ccmsetup/current/ccmsetup.log | 2 + .../client/health/contradictory/expected.json | 12 ++ .../client/health/contradictory/manifest.json | 13 ++ .../client-ccmsetup/current/ccmsetup.log | 1 + .../client-evaluation/current/CcmEval.log | 1 + .../current/ClientIDManagerStartup.log | 1 + .../health/identity-failure/expected.json | 12 ++ .../health/identity-failure/manifest.json | 13 ++ .../client-ccmsetup/current/ccmsetup.log | 1 + .../client-evaluation/current/CcmEval.log | 1 + .../client/health/incomplete/expected.json | 12 ++ .../client/health/incomplete/manifest.json | 13 ++ .../client-ccmsetup/current/ccmsetup.log | 1 + .../client-evaluation/current/CcmEval.log | 1 + .../client/health/malformed/expected.json | 12 ++ .../client/health/malformed/manifest.json | 13 ++ .../client-ccmsetup/current/ccmsetup.log | 1 + .../client-evaluation/current/CcmEval.log | 1 + .../current/ClientIDManagerStartup.log | 1 + .../current/LocationServices.log | 3 + .../client/health/no-site-or-mp/expected.json | 13 ++ .../client/health/no-site-or-mp/manifest.json | 13 ++ .../client-ccmsetup/current/ccmsetup.log | 1 + .../client-ccmsetup/lo/ccmsetup.log.lo_ | 1 + .../health/rotation-boundary/expected.json | 12 ++ .../health/rotation-boundary/manifest.json | 14 ++ .../client-ccmsetup/current/ccmsetup.log | 1 + .../client/health/setup-failure/expected.json | 12 ++ .../client/health/setup-failure/manifest.json | 13 ++ .../client-ccmsetup/current/ccmsetup.log | 1 + .../client-evaluation/current/CcmEval.log | 1 + .../current/ClientIDManagerStartup.log | 1 + .../current/LocationServices.log | 4 + .../sccm/client/health/success/expected.json | 11 ++ .../sccm/client/health/success/manifest.json | 13 ++ .../client-ccmsetup/current/ccmsetup.log | 1 + .../client-evaluation/current/CcmEval.log | 1 + .../current/ClientIDManagerStartup.log | 1 + .../current/LocationServices.log | 4 + .../health/transport-failure/expected.json | 12 ++ .../health/transport-failure/manifest.json | 13 ++ .../issue-320-client-health-corpus.md | 137 ++++++++++++++++++ 42 files changed, 395 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/evidence/client-ccmsetup/current/ccmsetup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/evidence/client-ccmsetup/current/ccmsetup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/evidence/client-evaluation/current/CcmEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/evidence/client-identity/current/ClientIDManagerStartup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/evidence/client-ccmsetup/current/ccmsetup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/evidence/client-evaluation/current/CcmEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/malformed/evidence/client-ccmsetup/current/ccmsetup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/malformed/evidence/client-evaluation/current/CcmEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/malformed/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/malformed/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/evidence/client-ccmsetup/current/ccmsetup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/evidence/client-evaluation/current/CcmEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/evidence/client-identity/current/ClientIDManagerStartup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/evidence/client-location-services-shared/current/LocationServices.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/evidence/client-ccmsetup/current/ccmsetup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/evidence/client-ccmsetup/lo/ccmsetup.log.lo_ create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/evidence/client-ccmsetup/current/ccmsetup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-ccmsetup/current/ccmsetup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-evaluation/current/CcmEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-identity/current/ClientIDManagerStartup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-location-services-shared/current/LocationServices.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-ccmsetup/current/ccmsetup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-evaluation/current/CcmEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-identity/current/ClientIDManagerStartup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-location-services-shared/current/LocationServices.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/manifest.json create mode 100644 docs/sccm/preparation/issue-320-client-health-corpus.md 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..046fba428 --- /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..ff37b5ec0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/expected.json @@ -0,0 +1,12 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "contradictory", + "workflow": "health", + "workflowDiagnosisExpected": true, + "lastSuccessfulPhase": null, + "findings": [ + {"findingId":"health-setup-contradictory","class":"symptom","phase":"setup","role":"client","confidence":"low","fixtureEvidence":[{"artifactId":"health-contradictory-ccmsetup-current","entryId":"entry-000001","lineStart":1,"lineEnd":1},{"artifactId":"health-contradictory-ccmsetup-current","entryId":"entry-000002","lineStart":2,"lineEnd":2}],"coverageGapArtifactIds":[],"nextArtifacts":[{"logicalArtifactId":"client-ccmsetup","reason":"capture a complete ordered bootstrap sequence for one validated bootstrapId"}],"mustNotClaim":["recovered setup","confirmed setup failure","management point cause"]} + ], + "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"absent"},{"logicalArtifactId":"client-identity","state":"absent"},{"logicalArtifactId":"client-location","state":"absent"}], + "assertions": ["later success with bootstrapId BOOT-TEST-B cannot recover terminal BOOT-TEST-A","same-key ordered recovery is a separate focused mutation"] +} 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..0cf9bbf3c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/manifest.json @@ -0,0 +1,13 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "contradictory", + "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"health-contradictory-ccmsetup-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-root-a-health-contradictory-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:50:02Z","bytesCopied":461,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-contradictory-evaluation-absent","designOnlyCatalog":{"entryId":"client-evaluation","groupMemberships":["client-evaluation"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"CcmEval.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-contradictory-evaluation-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:50:01Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"health-contradictory-identity-absent","designOnlyCatalog":{"entryId":"client-identity","groupMemberships":["client-identity"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"ClientIDManagerStartup.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-contradictory-identity-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:50:02Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"health-contradictory-location-services-absent","designOnlyCatalog":{"entryId":"client-location-services-shared","groupMemberships":["client-content","client-location"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"LocationServices.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-contradictory-location-services-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","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..7e5375ba8 --- /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..5ca45f9f3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/evidence/client-evaluation/current/CcmEval.log @@ -0,0 +1 @@ + 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..6ec06a89c --- /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..c7d86898d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/expected.json @@ -0,0 +1,12 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "identity-failure", + "workflow": "health", + "workflowDiagnosisExpected": true, + "lastSuccessfulPhase": "service", + "findings": [ + {"findingId":"health-identity-terminal","class":"confirmedFailure","phase":"identity","role":"client","confidence":"high","fixtureEvidence":[{"artifactId":"health-identity-failure-identity-current","entryId":"entry-000001","lineStart":1,"lineEnd":1}],"coverageGapArtifactIds":[],"nextArtifacts":[],"mustNotClaim":["site assignment failure","management point failure","management point cause"]} + ], + "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"captured"},{"logicalArtifactId":"client-identity","state":"captured"},{"logicalArtifactId":"client-location","state":"absent"}], + "assertions": ["identity terminal record carries the validated clientGuid","site and MP are not inferred after identity failure"] +} 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..66f45cf0d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/identity-failure/manifest.json @@ -0,0 +1,13 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "identity-failure", + "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"health-identity-failure-ccmsetup-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-root-a-health-identity-failure-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:20:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-identity-failure-evaluation-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-root-a-health-identity-failure-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:20:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, + {"artifactId":"health-identity-failure-identity-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-root-a-health-identity-failure-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:20:02Z","bytesCopied":290,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, + {"artifactId":"health-identity-failure-location-services-absent","designOnlyCatalog":{"entryId":"client-location-services-shared","groupMemberships":["client-content","client-location"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"LocationServices.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-identity-failure-location-services-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","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..6e8a3364d --- /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..d42a8fc98 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/evidence/client-evaluation/current/CcmEval.log @@ -0,0 +1 @@ + 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..bb2cc3ddb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/expected.json @@ -0,0 +1,12 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "incomplete", + "workflow": "health", + "workflowDiagnosisExpected": true, + "lastSuccessfulPhase": "service", + "findings": [ + {"findingId":"health-identity-coverage-gap","class":"insufficientEvidence","phase":"identity","role":"client","confidence":"low","fixtureEvidence":[],"coverageGapArtifactIds":["health-incomplete-identity-access-denied"],"nextArtifacts":[{"logicalArtifactId":"client-identity","reason":"capture the identity registration outcome with readable source coverage"}],"mustNotClaim":["identity failure","site assignment failure","management point cause"]} + ], + "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"captured"},{"logicalArtifactId":"client-identity","state":"accessDenied"},{"logicalArtifactId":"client-location","state":"absent"}], + "assertions": ["accessDenied is explicit coverage, not a failure diagnosis","the smallest next artifact is client-identity"] +} 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..7b79bf97c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/manifest.json @@ -0,0 +1,13 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "incomplete", + "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"health-incomplete-ccmsetup-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-root-a-health-incomplete-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:20:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-incomplete-evaluation-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-root-a-health-incomplete-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:20:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, + {"artifactId":"health-incomplete-identity-access-denied","designOnlyCatalog":{"entryId":"client-identity","groupMemberships":["client-identity"]},"role":"client","kind":"ccmLog","captureState":"accessDenied","encoding":null,"collectionLimit":null,"originalBasename":"ClientIDManagerStartup.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-incomplete-identity-access-denied","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:20:02Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"health-incomplete-location-services-absent","designOnlyCatalog":{"entryId":"client-location-services-shared","groupMemberships":["client-content","client-location"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"LocationServices.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-incomplete-location-services-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","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..d64eb5120 --- /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..e539a541a --- /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..65e1dfcfc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/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-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..15846336f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/evidence/client-identity/current/ClientIDManagerStartup.log @@ -0,0 +1 @@ + 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..302b18260 --- /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..5fab01e39 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/expected.json @@ -0,0 +1,13 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "no-site-or-mp", + "workflow": "health", + "workflowDiagnosisExpected": true, + "lastSuccessfulPhase": "identity", + "findings": [ + {"findingId":"health-site-assignment-insufficient","class":"insufficientEvidence","phase":"siteAssignment","role":"client","confidence":"low","fixtureEvidence":[{"artifactId":"health-no-site-or-mp-location-services-current","entryId":"entry-000001","lineStart":1,"lineEnd":1}],"coverageGapArtifactIds":[],"nextArtifacts":[{"logicalArtifactId":"client-location","reason":"capture a complete site-assignment and MP-response sequence"}],"mustNotClaim":["client is unassigned","management point is unavailable","management point cause"]}, + {"findingId":"health-unkeyed-transport-symptom","class":"symptom","phase":"transport","role":"client","confidence":"low","fixtureEvidence":[{"artifactId":"health-no-site-or-mp-location-services-current","entryId":"entry-000002","lineStart":2,"lineEnd":2},{"artifactId":"health-no-site-or-mp-location-services-current","entryId":"entry-000003","lineStart":3,"lineEnd":3}],"coverageGapArtifactIds":[],"nextArtifacts":[],"mustNotClaim":["transport failure","management point failure","management point cause"]} + ], + "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"captured"},{"logicalArtifactId":"client-identity","state":"captured"},{"logicalArtifactId":"client-location","state":"captured"}], + "assertions": ["same-minute unkeyed network error is a symptom only","hostname-shaped unrelated text is not MP evidence"] +} 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..566699f90 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/no-site-or-mp/manifest.json @@ -0,0 +1,13 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "no-site-or-mp", + "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"health-no-site-or-mp-ccmsetup-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-root-a-health-no-site-or-mp-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-no-site-or-mp-evaluation-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-root-a-health-no-site-or-mp-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, + {"artifactId":"health-no-site-or-mp-identity-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-root-a-health-no-site-or-mp-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:02Z","bytesCopied":265,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, + {"artifactId":"health-no-site-or-mp-location-services-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-root-a-health-no-site-or-mp-location-services-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:04Z","bytesCopied":617,"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.log.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/evidence/client-ccmsetup/lo/ccmsetup.log.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.log.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..46ce3cb41 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/expected.json @@ -0,0 +1,12 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "setup-failure", + "workflow": "health", + "workflowDiagnosisExpected": true, + "lastSuccessfulPhase": null, + "findings": [ + {"findingId":"health-setup-terminal","class":"confirmedFailure","phase":"setup","role":"client","confidence":"high","fixtureEvidence":[{"artifactId":"health-setup-failure-ccmsetup-current","entryId":"entry-000001","lineStart":1,"lineEnd":1}],"coverageGapArtifactIds":[],"nextArtifacts":[],"mustNotClaim":["service failure","identity failure","management point cause"]} + ], + "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"absent"},{"logicalArtifactId":"client-identity","state":"absent"},{"logicalArtifactId":"client-location","state":"absent"}], + "assertions": ["terminal setup evidence is profile-validated","no later same-key bootstrap success exists"] +} 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..246c2e4fa --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/manifest.json @@ -0,0 +1,13 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "setup-failure", + "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"health-setup-failure-ccmsetup-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-root-a-health-setup-failure-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:10:00Z","bytesCopied":242,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-setup-failure-evaluation-absent","designOnlyCatalog":{"entryId":"client-evaluation","groupMemberships":["client-evaluation"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"CcmEval.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-setup-failure-evaluation-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:10:01Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"health-setup-failure-identity-absent","designOnlyCatalog":{"entryId":"client-identity","groupMemberships":["client-identity"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"ClientIDManagerStartup.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-setup-failure-identity-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:10:02Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"health-setup-failure-location-services-absent","designOnlyCatalog":{"entryId":"client-location-services-shared","groupMemberships":["client-content","client-location"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"LocationServices.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-setup-failure-location-services-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","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..998b67375 --- /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..f69d95d00 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-evaluation/current/CcmEval.log @@ -0,0 +1 @@ + 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..483d560a8 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-identity/current/ClientIDManagerStartup.log @@ -0,0 +1 @@ + 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..3dcbd96ad --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/evidence/client-location-services-shared/current/LocationServices.log @@ -0,0 +1,4 @@ + + + + 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..bb767b063 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/expected.json @@ -0,0 +1,11 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "success", + "workflow": "health", + "workflowDiagnosisExpected": false, + "lastSuccessfulPhase": "transport", + "findings": [ + ], + "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"captured"},{"logicalArtifactId":"client-identity","state":"captured"},{"logicalArtifactId":"client-location","state":"captured"}], + "assertions": ["all phase transitions require source-local complete records","transport request and response share validated requestId and managementPointHost"] +} 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..6fa4d1a26 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/manifest.json @@ -0,0 +1,13 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "success", + "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"health-success-ccmsetup-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-root-a-health-success-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-success-evaluation-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-root-a-health-success-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, + {"artifactId":"health-success-identity-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-root-a-health-success-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:02Z","bytesCopied":265,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, + {"artifactId":"health-success-location-services-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-root-a-health-success-location-services-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:07Z","bytesCopied":962,"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..7ee27ea67 --- /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..b17774230 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-evaluation/current/CcmEval.log @@ -0,0 +1 @@ + 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..2e96b38c0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-identity/current/ClientIDManagerStartup.log @@ -0,0 +1 @@ + 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..76a9c62bf --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/evidence/client-location-services-shared/current/LocationServices.log @@ -0,0 +1,4 @@ + + + + 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..7c4e7f3f1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/expected.json @@ -0,0 +1,12 @@ +{ + "contractState": "proposedPending318And319", + "scenario": "transport-failure", + "workflow": "health", + "workflowDiagnosisExpected": true, + "lastSuccessfulPhase": "managementPoint", + "findings": [ + {"findingId":"health-transport-terminal","class":"confirmedFailure","phase":"transport","role":"client","confidence":"high","fixtureEvidence":[{"artifactId":"health-transport-failure-location-services-current","entryId":"entry-000003","lineStart":3,"lineEnd":3},{"artifactId":"health-transport-failure-location-services-current","entryId":"entry-000004","lineStart":4,"lineEnd":4}],"coverageGapArtifactIds":[],"nextArtifacts":[],"mustNotClaim":["management point caused the failure","server-side failure","network root cause"]} + ], + "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"captured"},{"logicalArtifactId":"client-identity","state":"captured"},{"logicalArtifactId":"client-location","state":"captured"}], + "assertions": ["terminal response shares validated requestId and managementPointHost with request","finding is limited to the client transport observation"] +} 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..c7c6942f3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/transport-failure/manifest.json @@ -0,0 +1,13 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "transport-failure", + "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "artifacts": [ + {"artifactId":"health-transport-failure-ccmsetup-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-root-a-health-transport-failure-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:40:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-transport-failure-evaluation-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-root-a-health-transport-failure-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:40:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, + {"artifactId":"health-transport-failure-identity-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-root-a-health-transport-failure-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:40:02Z","bytesCopied":265,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, + {"artifactId":"health-transport-failure-location-services-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-root-a-health-transport-failure-location-services-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:40:07Z","bytesCopied":966,"relativePath":"evidence/client-location-services-shared/current/LocationServices.log"} + ] +} 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..e7f50cdf7 --- /dev/null +++ b/docs/sccm/preparation/issue-320-client-health-corpus.md @@ -0,0 +1,137 @@ +# 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`, `CONTOSO`, 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. From 262a18dedff5934868ed2f89d82795c412935733 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:29:16 -0400 Subject: [PATCH 009/422] feat(sccm): establish diagnostic spine Phase A (#336) Implements the reviewed Phase A SCCM spine contracts for issue #318, including pure-Rust artifact/coverage/provenance models, exact role-aware catalog classification, conservative rotation grammar, and canonical ConfigMgr .lo_ handling. Validation: 592 parser tests, strict Clippy, wasm32, TypeScript, scoped Rust 1.88 formatting, and diff checks. CodeRabbit and independent exact-head review passed. Native Windows acceptance remains pending and is not claimed. Part of #318. --- crates/cmtraceopen-parser/src/lib.rs | 1 + crates/cmtraceopen-parser/src/sccm/catalog.rs | 569 +++++++++++ crates/cmtraceopen-parser/src/sccm/mod.rs | 6 + crates/cmtraceopen-parser/src/sccm/models.rs | 274 ++++++ .../cmtraceopen-parser/src/sccm/rotation.rs | 26 + .../sccm/spine/artifact-manifest.json | 59 ++ .../tests/sccm_spine_contract.rs | 910 ++++++++++++++++++ 7 files changed, 1845 insertions(+) create mode 100644 crates/cmtraceopen-parser/src/sccm/catalog.rs create mode 100644 crates/cmtraceopen-parser/src/sccm/mod.rs create mode 100644 crates/cmtraceopen-parser/src/sccm/models.rs create mode 100644 crates/cmtraceopen-parser/src/sccm/rotation.rs create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/spine/artifact-manifest.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_spine_contract.rs diff --git a/crates/cmtraceopen-parser/src/lib.rs b/crates/cmtraceopen-parser/src/lib.rs index e687bc703..6500ba3c5 100644 --- a/crates/cmtraceopen-parser/src/lib.rs +++ b/crates/cmtraceopen-parser/src/lib.rs @@ -14,3 +14,4 @@ pub mod esp; pub mod intune; pub mod models; pub mod parser; +pub mod sccm; diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs new file mode 100644 index 000000000..f453b2035 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -0,0 +1,569 @@ +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, + SiteComponent, + SiteStatus, + ManagementPoint, + DistributionPoint, + SoftwareUpdatePoint, + Hierarchy, + Provider, + AdminService, + Unknown(String), +} + +impl SccmArtifactFamily { + 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::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 == "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, +} + +const SOURCE_CATALOG: &[CatalogSpec] = &[ + CatalogSpec { + basename: "CCMSetup", + logical_name: "ccmSetup", + 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: "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: "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: "smsts", + logical_name: "smsts", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientTaskSequence, + }, + 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::ManagementPoint, + 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: "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::Provider, + 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: true, + 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: true, + supported_for_diagnosis: true, + } +} + +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/mod.rs b/crates/cmtraceopen-parser/src/sccm/mod.rs new file mode 100644 index 000000000..66ef4eda6 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/mod.rs @@ -0,0 +1,6 @@ +pub mod catalog; +pub mod models; +mod rotation; + +pub use catalog::*; +pub use models::*; diff --git a/crates/cmtraceopen-parser/src/sccm/models.rs b/crates/cmtraceopen-parser/src/sccm/models.rs new file mode 100644 index 000000000..38f9d2253 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/models.rs @@ -0,0 +1,274 @@ +use serde::ser::{Error as _, SerializeStruct}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; + +use super::rotation::{is_canonical_rotation_number, is_canonical_rotation_timestamp}; + +pub const SCCM_DIAGNOSTICS_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, 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, + } + } +} + +impl Serialize for SccmRole { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.serialized_name()) + } +} + +impl<'de> Deserialize<'de> for SccmRole { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(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), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmFindingClass { + Symptom, + ConfirmedFailure, + BlockedOrDeferred, + LikelyContributor, + InsufficientEvidence, +} + +impl SccmFindingClass { + pub fn as_str(&self) -> &'static str { + match self { + Self::Symptom => "symptom", + Self::ConfirmedFailure => "confirmedFailure", + Self::BlockedOrDeferred => "blockedOrDeferred", + Self::LikelyContributor => "likelyContributor", + Self::InsufficientEvidence => "insufficientEvidence", + } + } +} + +#[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/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/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs new file mode 100644 index 000000000..63071f29b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -0,0 +1,910 @@ +use cmtraceopen_parser::models::log_entry::ParserKind; +use cmtraceopen_parser::parser::detect::detect_parser; +use cmtraceopen_parser::sccm::{ + classify_artifact_name, declared_source_catalog, SccmArtifact, SccmArtifactFamily, + SccmCoverageState, SccmFindingClass, SccmRole, SccmRotation, SccmUnknownRotation, + 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" + ); +} + +#[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, + ), + ( + "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::Provider, + 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, + ), + ( + "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, + ), + ( + "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, + ), + ( + "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, + ), + ( + "smsts.log", + SccmRole::Client, + "smsts", + SccmArtifactFamily::ClientTaskSequence, + 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::ManagementPoint, + "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, + ), + ( + "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::Provider, + "adminService", + SccmArtifactFamily::AdminService, + true, + true, + ), + ] +} From 99554f4aef0c06b2ce81eaf257c76058c38c06c0 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:11:50 -0400 Subject: [PATCH 010/422] test(sccm): correct server intake fixture contract (#345) Canonicalize the synthetic server-intake site and rollover contract, preserve parser fixture bytes across Windows-style checkouts, and add the focused cross-platform corpus guard. Refs #335. --- .gitattributes | 2 + .../tests/fixtures/sccm/server/README.md | 11 +- .../server/intake/absent-dp/manifest.json | 2 +- .../intake/access-denied-mp/manifest.json | 2 +- .../server/intake/capped-sup/manifest.json | 2 +- .../manifest.json | 2 +- .../intake/complete-multi-role/manifest.json | 2 +- .../configured-nondefault-path/manifest.json | 2 +- .../server/intake/multiline/manifest.json | 2 +- ...{MP_GetPolicy.log.lo_ => MP_GetPolicy.lo_} | 0 .../server/intake/rotations/manifest.json | 4 +- .../server/intake/skipped-iis/manifest.json | 2 +- .../intake/unsorted-manifest/manifest.json | 2 +- .../unsupported-db-supplement/manifest.json | 2 +- .../sccm_server_intake_fixture_contract.rs | 108 ++++++++++++++++++ .../preparation/issue-335-server-intake.md | 8 +- .../2026-07-30-sccm-server-intake-and-core.md | 4 +- 17 files changed, 134 insertions(+), 23 deletions(-) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/lo_/{MP_GetPolicy.log.lo_ => MP_GetPolicy.lo_} (100%) create mode 100644 crates/cmtraceopen-parser/tests/sccm_server_intake_fixture_contract.rs 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/crates/cmtraceopen-parser/tests/fixtures/sccm/server/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/README.md index b45b676ea..290069023 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/README.md +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/README.md @@ -1,11 +1,12 @@ # Synthetic SCCM server intake fixtures -These fixtures prepare issue #335 while #318 owns the shared SCCM schema. They -are intentionally not connected to a Rust test target yet. Every value is -synthetic, deterministic, and privacy-safe: +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 labels are `LAB-CM01`, `LAB-MP01`, `LAB-DP01`, and - `CONTOSO`; +- 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`; 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 index eb6b2b158..17e21458b 100644 --- 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 @@ -4,7 +4,7 @@ "proposalOnly": true, "privacy": { "synthetic": true, "rawPaths": "redacted" }, "bundleRole": "server", - "topology": { "captureHost": "LAB-CM01", "siteCode": "CONTOSO", "rolesObserved": ["siteServer"] }, + "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/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/access-denied-mp/manifest.json index 5b5bea22f..cb93c009e 100644 --- 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 @@ -4,7 +4,7 @@ "proposalOnly": true, "privacy": { "synthetic": true, "rawPaths": "redacted" }, "bundleRole": "server", - "topology": { "captureHost": "LAB-MP01", "siteCode": "CONTOSO", "rolesObserved": ["managementPoint"] }, + "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/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/capped-sup/manifest.json index a1fcf9c5e..300ba634f 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/capped-sup/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/capped-sup/manifest.json @@ -4,7 +4,7 @@ "proposalOnly": true, "privacy": { "synthetic": true, "rawPaths": "redacted" }, "bundleRole": "server", - "topology": { "captureHost": "LAB-CM01", "siteCode": "CONTOSO", "rolesObserved": ["siteServer"] }, + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, "artifacts": [ { "artifactId": "sup-sync-capped", "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-cap" }, "captureState": "capped", "encoding": "utf-8", "collectionLimit": { "byteLimit": 64, "limitApplied": true }, "bytesCopied": 64, "truncated": true, "fragmentComplete": false, "collectedUtc": "2026-07-30T00:06:00Z", "relativePath": "evidence/sccm/server/site-server/server-sup-sync/subject-software-update-point/instance-17eae15500d8968f/root-b11afca548220198/current/wsyncmgr.log" } ] 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 index 2bbf6cbcc..32d856c01 100644 --- 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 @@ -4,7 +4,7 @@ "proposalOnly": true, "privacy": { "synthetic": true, "rawPaths": "redacted" }, "bundleRole": "server", - "topology": { "captureHost": "LAB-MP01", "siteCode": "CONTOSO", "rolesObserved": ["managementPoint"] }, + "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/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/complete-multi-role/manifest.json index 268f6e673..515dd4a60 100644 --- 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 @@ -4,7 +4,7 @@ "proposalOnly": true, "privacy": { "synthetic": true, "rawPaths": "redacted" }, "bundleRole": "server", - "topology": { "captureHost": "LAB-CM01", "siteCode": "CONTOSO", "rolesObserved": ["siteServer", "managementPoint", "distributionPoint", "softwareUpdatePoint"] }, + "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 }, 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 index 58b4a187e..3986a09b4 100644 --- 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 @@ -4,7 +4,7 @@ "proposalOnly": true, "privacy": { "synthetic": true, "rawPaths": "redacted" }, "bundleRole": "server", - "topology": { "captureHost": "LAB-MP01", "siteCode": "CONTOSO", "rolesObserved": ["managementPoint"] }, + "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/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/manifest.json index 1bae87c0a..64b91d9bb 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/multiline/manifest.json @@ -4,7 +4,7 @@ "proposalOnly": true, "privacy": { "synthetic": true, "rawPaths": "redacted" }, "bundleRole": "server", - "topology": { "captureHost": "LAB-MP01", "siteCode": "CONTOSO", "rolesObserved": ["managementPoint"] }, + "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/lo_/MP_GetPolicy.log.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/lo_/MP_GetPolicy.lo_ similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/lo_/MP_GetPolicy.log.lo_ rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/evidence/sccm/server/management-point/server-mp-policy/lo_/MP_GetPolicy.lo_ 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 index 2961af624..48051361c 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/rotations/manifest.json @@ -4,11 +4,11 @@ "proposalOnly": true, "privacy": { "synthetic": true, "rawPaths": "redacted" }, "bundleRole": "server", - "topology": { "captureHost": "LAB-MP01", "siteCode": "CONTOSO", "rolesObserved": ["managementPoint"] }, + "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.log.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.log.lo_", "bytesCopied": 177 }, + { "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/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/skipped-iis/manifest.json index e206619f2..74e1ebf47 100644 --- 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 @@ -4,7 +4,7 @@ "proposalOnly": true, "privacy": { "synthetic": true, "rawPaths": "redacted" }, "bundleRole": "server", - "topology": { "captureHost": "LAB-MP01", "siteCode": "CONTOSO", "rolesObserved": ["managementPoint"] }, + "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/unsorted-manifest/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/manifest.json index 12d37be4e..28e9c338f 100644 --- 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 @@ -4,7 +4,7 @@ "proposalOnly": true, "privacy": { "synthetic": true, "rawPaths": "redacted" }, "bundleRole": "server", - "topology": { "captureHost": "LAB-CM01", "siteCode": "CONTOSO", "rolesObserved": ["siteServer", "managementPoint"] }, + "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 }, 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 index 998d66d2c..0455b1202 100644 --- 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 @@ -4,7 +4,7 @@ "proposalOnly": true, "privacy": { "synthetic": true, "rawPaths": "redacted" }, "bundleRole": "server", - "topology": { "captureHost": "LAB-CM01", "siteCode": "CONTOSO", "rolesObserved": ["siteServer"] }, + "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/sccm_server_intake_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake_fixture_contract.rs new file mode 100644 index 000000000..62aba00bb --- /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(), 11, "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/docs/sccm/preparation/issue-335-server-intake.md b/docs/sccm/preparation/issue-335-server-intake.md index cd6b66fea..4c2551352 100644 --- a/docs/sccm/preparation/issue-335-server-intake.md +++ b/docs/sccm/preparation/issue-335-server-intake.md @@ -169,10 +169,10 @@ Deferred native tests must make the write/privacy boundaries observable: 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 `.log.lo_`, `.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. + 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 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 index 55ad7dc2e..2c65230ab 100644 --- 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 @@ -21,7 +21,7 @@ - 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, CONTOSO, and synthetic keys. +- 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. --- @@ -100,7 +100,7 @@ The server manifest needs enough information to interpret evidence without query "topology": { "captureHost": "LAB-CM01", "rolesObserved": ["siteServer", "managementPoint"], - "siteCode": "CONTOSO" + "siteCode": "LAB" }, "artifacts": [{ "artifactId": "server-mp-get-policy", From 690cb32240a86001fdc4c3acc58e63638814d20e Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:17:07 -0400 Subject: [PATCH 011/422] docs(sccm): prepare Management Point corpus (#343) Add the role-aware synthetic Management Point scenario corpus and conservative #333 handoff contract. Tracks #328; closes no issue. --- .../server-mp-auth/current/MP_GetAuth.log | 2 + .../auth-failure/expected.json | 51 +++ .../auth-failure/manifest.json | 39 +++ .../server-mp-auth/current/MP_CliReg.log | 1 + .../server-mp-auth/current/MP_GetAuth.log | 2 + .../server-mp-policy/current/MP_GetPolicy.log | 4 + .../healthy-policy/expected.json | 56 ++++ .../healthy-policy/manifest.json | 79 +++++ .../server-mp-auth/current/MP_CliReg.log | 1 + .../server-mp-auth/current/MP_GetAuth.log | 2 + .../server-mp-policy/current/MP_GetPolicy.log | 3 + .../server-mp-policy/current/mpcontrol.log | 1 + .../iis-supplemental/expected.json | 60 ++++ .../iis-supplemental/manifest.json | 117 +++++++ .../server-mp-auth/current/MP_CliReg.log | 1 + .../server-mp-auth/current/MP_GetAuth.log | 2 + .../management-point/incomplete/expected.json | 52 +++ .../management-point/incomplete/manifest.json | 77 +++++ .../server-mp-auth/current/MP_CliReg.log | 1 + .../server-mp-auth/current/MP_GetAuth.log | 2 + .../server-mp-policy/current/MP_Location.log | 1 + .../location-failure/expected.json | 62 ++++ .../location-failure/manifest.json | 79 +++++ .../server-mp-auth/current/MP_CliReg.log | 2 + .../server-mp-auth/current/MP_GetAuth.log | 4 + .../server-mp-policy/current/MP_GetPolicy.log | 5 + .../policy-failure/expected.json | 110 +++++++ .../policy-failure/manifest.json | 79 +++++ .../server-mp-auth/current/MP_GetAuth.log | 2 + .../current/MP_RegistrationManager.log | 1 + .../registration-failure/expected.json | 56 ++++ .../registration-failure/manifest.json | 59 ++++ .../server-mp-auth/current/MP_GetAuth.log | 1 + .../evidence/server-mp-auth/lo/MP_GetAuth.lo_ | 1 + .../numbered-2/MP_GetAuth.log.2 | 1 + .../rotation-boundary/expected.json | 34 ++ .../rotation-boundary/manifest.json | 79 +++++ .../server-mp-policy/current/MP_GetPolicy.log | 1 + .../unrelated-client-like-key/expected.json | 33 ++ .../unrelated-client-like-key/manifest.json | 57 ++++ .../issue-328-management-point-corpus.md | 299 ++++++++++++++++++ 41 files changed, 1519 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/auth-failure/evidence/server-mp-auth/current/MP_GetAuth.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/auth-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/auth-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/evidence/server-mp-auth/current/MP_CliReg.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/evidence/server-mp-auth/current/MP_GetAuth.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/evidence/server-mp-policy/current/MP_GetPolicy.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-auth/current/MP_CliReg.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-auth/current/MP_GetAuth.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-policy/current/MP_GetPolicy.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/evidence/server-mp-policy/current/mpcontrol.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/evidence/server-mp-auth/current/MP_CliReg.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/evidence/server-mp-auth/current/MP_GetAuth.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/evidence/server-mp-auth/current/MP_CliReg.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/evidence/server-mp-auth/current/MP_GetAuth.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/evidence/server-mp-policy/current/MP_Location.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/evidence/server-mp-auth/current/MP_CliReg.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/evidence/server-mp-auth/current/MP_GetAuth.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/evidence/server-mp-policy/current/MP_GetPolicy.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/evidence/server-mp-auth/current/MP_GetAuth.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/evidence/server-mp-auth/current/MP_RegistrationManager.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/registration-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary/evidence/server-mp-auth/current/MP_GetAuth.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary/evidence/server-mp-auth/lo/MP_GetAuth.lo_ create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary/evidence/server-mp-auth/numbered-2/MP_GetAuth.log.2 create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key/evidence/server-mp-policy/current/MP_GetPolicy.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key/manifest.json create mode 100644 docs/sccm/preparation/issue-328-management-point-corpus.md 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..89ae7d084 --- /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.0000","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..e917f861e --- /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.0000", + "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/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..40c45f3c8 --- /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.0000","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.0000","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.0000","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..a643b1b7a --- /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.0000", + "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.0000", + "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.0000", + "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..7403d5e6b --- /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.0000","collectedUtc":"2026-07-30T06:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"artifactId":"mp-iis-control-current","captureState":"captured","role":"managementPoint","producer":"SMS_MP_CONTROL_MANAGER","pathFingerprint":"synthetic:iis-role-context","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST.0000","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.0000","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.0000","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..0d56c9f64 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/iis-supplemental/manifest.json @@ -0,0 +1,117 @@ +{ + "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.0000", + "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.0000", + "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.0000", + "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": "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.0000", + "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..748714bf1 --- /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.0000","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.0000","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.0000","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..74a96b765 --- /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.0000", + "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.0000", + "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.0000", + "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..b5af813a6 --- /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.0000","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.0000","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.0000","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..6921c8ea0 --- /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.0000", + "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.0000", + "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.0000", + "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..bd0925ad3 --- /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.0000","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.0000","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.0000","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..6dd3aa8a2 --- /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.0000", + "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.0000", + "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.0000", + "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..4256cfa59 --- /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.0000","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.0000","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..351fd1848 --- /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.0000", + "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.0000", + "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..d987d83d8 --- /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.0000","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.0000","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","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:mp-rotation-malformed","subjectId":"observation:rotation-malformed","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..f5db8f5e5 --- /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.0000", + "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.0000", + "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..b13d29e2d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key/expected.json @@ -0,0 +1,33 @@ +{ + "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.0000","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.0000","collectedUtc":"2026-07-30T08:00:05Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + ], + "primaryTransactionId": null, + "transactions": [], + "sourceLocalObservations": [ + {"observationId":"observation:unrelated-client-like-key","key":null,"keyConfidence":"none","classification":"incompatibleKey","phase":"resolveLocationOrPolicy","lastSuccessfulPhase":null,"confidence":"low","confidenceCeiling":"low","correlationEligible":false,"borrowedKeys":false,"nextArtifact":{"logicalArtifactId":"server-mp-auth","reason":"Capture a bounded authentication-family source with the exact versioned request key."},"evidence":[{"artifactId":"mp-unrelated-policy-current","startLine":1,"endLine":1}]} + ], + "contextFacts": [], + "findings": [ + {"findingId":"finding:mp-unrelated-client-like-key","subjectId":"observation:unrelated-client-like-key","class":"lowConfidenceSymptom","phase":"resolveLocationOrPolicy","lastSuccessfulPhase":null,"confidence":"low","confidenceCeiling":"low","nextArtifact":{"logicalArtifactId":"server-mp-auth","reason":"Capture a bounded authentication-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..062e28f03 --- /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.0000", + "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.0000", + "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/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. From 6ef2482d20e132e9ca7db920a9e1199e2f8bd7ea Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:44:48 -0400 Subject: [PATCH 012/422] feat(sccm): add logical CCM evidence framing (#342) Part of #318. Preserve public LogEntry compatibility while adding internal logical CCM evidence framing, timestamp provenance, deterministic evidence references, and conservative public redaction. --- crates/cmtraceopen-parser/src/parser/ccm.rs | 478 ++++++++++---- .../cmtraceopen-parser/src/sccm/evidence.rs | 287 ++++++++ crates/cmtraceopen-parser/src/sccm/ingest.rs | 11 + crates/cmtraceopen-parser/src/sccm/mod.rs | 3 + crates/cmtraceopen-parser/src/sccm/models.rs | 47 ++ .../fixtures/sccm/spine/multiline-policy.log | 2 + .../tests/sccm_spine_contract.rs | 612 +++++++++++++++++- 7 files changed, 1299 insertions(+), 141 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/evidence.rs create mode 100644 crates/cmtraceopen-parser/src/sccm/ingest.rs create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/spine/multiline-policy.log diff --git a/crates/cmtraceopen-parser/src/parser/ccm.rs b/crates/cmtraceopen-parser/src/parser/ccm.rs index 32bba6b2a..9c4e301d8 100644 --- a/crates/cmtraceopen-parser/src/parser/ccm.rs +++ b/crates/cmtraceopen-parser/src/parser/ccm.rs @@ -24,10 +24,10 @@ fn ccm_re() -> &'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 +40,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 +54,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 +170,64 @@ pub(crate) fn truncate_subsecond_to_millis(value: &str) -> Option { } } +/// Split CCM's fractional-second field from its optional timezone offset. +/// +/// A signed offset is self-delimiting. The documented legacy `%03u%d` +/// grammar also permits a signless three-digit offset after exactly three +/// millisecond digits. Other digit-only tails are fractional seconds with no +/// source offset, including the seven-digit precision emitted by IME logs. +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..])); + } + + if value.len() == 6 { + 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)] @@ -153,8 +302,38 @@ 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); + ( + 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) + .records + .into_iter() + .filter(|record| record.entry.format == LogFormat::Ccm) + .collect() +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum CcmScanMode { + PublicProjection, + SccmEvidence, +} + +struct CcmScan { + records: Vec, + errors: u32, +} + +fn scan_ccm_content(content: &str, file_path: &str, mode: CcmScanMode) -> CcmScan { let line_starts = build_line_starts(content); - let mut entries: Vec = Vec::new(); + let mut records = Vec::new(); let mut errors = 0u32; let mut id_counter = 0u64; let mut cursor = 0usize; @@ -171,72 +350,36 @@ fn parse_content_multiline(content: &str, file_path: &str) -> (Vec, u3 cursor, &line_starts, file_path, - &mut entries, + &mut records, &mut id_counter, &mut errors, ); - // 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 { + records + .push(parsed.into_logical_record(id_counter, line_start, line_end, file_path)); + id_counter += 1; + } else { + push_unmatched_plain( + full_match.as_str(), + full_match.start(), + &line_starts, + file_path, + &mut records, + &mut id_counter, + &mut errors, + ); + } } else { push_unmatched_plain( full_match.as_str(), full_match.start(), &line_starts, file_path, - &mut entries, + &mut records, &mut id_counter, &mut errors, ); @@ -252,33 +395,55 @@ fn parse_content_multiline(content: &str, file_path: &str) -> (Vec, u3 cursor, &line_starts, file_path, - &mut entries, + &mut records, &mut id_counter, &mut errors, ); 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 }; } - (entries, errors) + CcmScan { records, errors } } /// 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. @@ -290,7 +455,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 { @@ -302,7 +511,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, }) } @@ -323,13 +535,13 @@ 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, + records: &mut Vec, id_counter: &mut u64, errors: &mut u32, ) { @@ -338,9 +550,10 @@ fn push_unmatched_plain( let line = piece.trim_end_matches(['\r', '\n']); let trimmed = line.trim(); if !trimmed.is_empty() { - entries.push(LogEntry { + let line_number = line_number_for_offset(line_starts, base_offset + local_offset); + let entry = LogEntry { id: *id_counter, - line_number: line_number_for_offset(line_starts, base_offset + local_offset), + line_number, message: trimmed.to_string(), component: None, timestamp: None, @@ -387,6 +600,13 @@ fn push_unmatched_plain( section_color: None, iteration: None, tags: None, + }; + records.push(CcmLogicalRecord { + entry, + context: None, + line_start: line_number, + line_end: line_number, + timestamp: CcmTimestampParse::missing(), }); *id_counter += 1; *errors += 1; @@ -418,56 +638,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 => { @@ -766,4 +942,32 @@ 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()); + } } diff --git a/crates/cmtraceopen-parser/src/sccm/evidence.rs b/crates/cmtraceopen-parser/src/sccm/evidence.rs new file mode 100644 index 000000000..c11a9d472 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/evidence.rs @@ -0,0 +1,287 @@ +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\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 + | 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 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 { + let redacted = redact_sensitive_segments(raw); + let identities_redacted = redact_windows_identities(&redacted); + let projected = redact_email_identities(&identities_redacted); + + format!("[{PUBLIC_MESSAGE_PROFILE}] {projected}") +} + +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 = sensitive_value_end(value, label.end()); + copied_through = value_end; + search_from = value_end; + } + + projected.push_str(&value[copied_through..]); + projected +} + +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; + + for matched in windows_identity_re().find_iter(value) { + let preceding = value[..matched.start()].chars().next_back(); + let following = value[matched.end()..].chars().next(); + let path_adjacent = + matches!(preceding, Some('\\' | '/')) || matches!(following, Some('\\' | '/')); + if path_adjacent { + continue; + } + + projected.push_str(&value[copied_through..matched.start()]); + projected.push_str(PUBLIC_MESSAGE_REDACTION); + copied_through = matched.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.clone(), + ccm_source_file: self.ccm_source_file.clone(), + 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); + } +} 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/mod.rs b/crates/cmtraceopen-parser/src/sccm/mod.rs index 66ef4eda6..6b82b5889 100644 --- a/crates/cmtraceopen-parser/src/sccm/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/mod.rs @@ -1,6 +1,9 @@ pub mod catalog; +mod evidence; +mod ingest; pub mod models; mod rotation; pub use catalog::*; +pub use ingest::*; pub use models::*; diff --git a/crates/cmtraceopen-parser/src/sccm/models.rs b/crates/cmtraceopen-parser/src/sccm/models.rs index 38f9d2253..afcc4dae1 100644 --- a/crates/cmtraceopen-parser/src/sccm/models.rs +++ b/crates/cmtraceopen-parser/src/sccm/models.rs @@ -97,6 +97,53 @@ impl SccmFindingClass { } } +#[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, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +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)] pub enum SccmRotation { Current, 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_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 63071f29b..99c43540d 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -1,11 +1,54 @@ -use cmtraceopen_parser::models::log_entry::ParserKind; +use cmtraceopen_parser::models::log_entry::{LogFormat, ParserKind}; use cmtraceopen_parser::parser::detect::detect_parser; use cmtraceopen_parser::sccm::{ - classify_artifact_name, declared_source_catalog, SccmArtifact, SccmArtifactFamily, - SccmCoverageState, SccmFindingClass, SccmRole, SccmRotation, SccmUnknownRotation, - SCCM_DIAGNOSTICS_SCHEMA_VERSION, + classify_artifact_name, declared_source_catalog, normalize_ccm_artifact, SccmArtifact, + SccmArtifactFamily, SccmCoverageState, SccmFindingClass, SccmRole, SccmRotation, + SccmTimeOrderingState, SccmUnknownRotation, 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 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); @@ -22,6 +65,567 @@ fn sccm_contract_is_public_and_versioned() { ); } +#[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 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 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_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 serde_roles_are_string_backed_and_future_tolerant() { assert_eq!( From 04fd2774d6cfada6f064a249a39673448d70f3a4 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:56:00 -0400 Subject: [PATCH 013/422] test(sccm): prepare client policy corpus (#341) Part of #321. Add the synthetic policy scenario matrix, exact fixture contracts, counterpart-ready facts, and conservative preparation documentation. No production reducer or native acceptance. --- .../current/PolicyAgent.log | 3 + .../client-policy-agent/current/Scheduler.log | 1 + .../client-policy-state/current/CIAgent.log | 1 + .../current/StateMessage.log | 1 + .../sccm/client/policy/complete/expected.json | 39 ++++ .../sccm/client/policy/complete/manifest.json | 12 + .../current/PolicyAgent.log | 6 + .../client-policy-agent/current/Scheduler.log | 2 + .../current/StateMessage.log | 1 + .../root-a/current/CIAgent.log | 2 + .../root-b/current/CIAgent.log | 1 + .../policy/contradictory-offset/expected.json | 95 ++++++++ .../policy/contradictory-offset/manifest.json | 18 ++ .../current/PolicyAgent.log | 2 + .../policy/download-failure/expected.json | 39 ++++ .../policy/download-failure/manifest.json | 9 + .../current/PolicyAgent.log | 3 + .../client-policy-agent/current/Scheduler.log | 1 + .../client-policy-state/current/CIAgent.log | 1 + .../policy/evaluation-failure/expected.json | 47 ++++ .../policy/evaluation-failure/manifest.json | 11 + .../current/PolicyAgent.log | 6 + .../client-policy-agent/current/Scheduler.log | 2 + .../current/StateMessage.log | 1 + .../root-a/current/CIAgent.log | 2 + .../root-b/current/CIAgent.log | 1 + .../policy/gate-c-contradictory/expected.json | 98 ++++++++ .../policy/gate-c-contradictory/manifest.json | 18 ++ .../current/PolicyAgent.log | 3 + .../client-policy-agent/current/Scheduler.log | 1 + .../client/policy/incomplete/expected.json | 45 ++++ .../client/policy/incomplete/manifest.json | 11 + .../current/PolicyAgent.log | 1 + .../client/policy/malformed/expected.json | 39 ++++ .../client/policy/malformed/manifest.json | 9 + .../current/PolicyAgent.log | 4 + .../client-policy-agent/current/Scheduler.log | 1 + .../client-policy-state/current/CIAgent.log | 1 + .../current/StateMessage.log | 1 + .../client/policy/multiline/expected.json | 45 ++++ .../client/policy/multiline/manifest.json | 12 + .../current/PolicyAgent.log | 3 + .../policy/persist-failure/expected.json | 39 ++++ .../policy/persist-failure/manifest.json | 9 + .../current/PolicyAgent.log | 4 + .../client-policy-agent/current/Scheduler.log | 1 + .../client-policy-state/current/CIAgent.log | 1 + .../current/StateMessage.log | 1 + .../sccm/client/policy/recovery/expected.json | 47 ++++ .../sccm/client/policy/recovery/manifest.json | 12 + .../current/PolicyAgent.log | 3 + .../client-policy-agent/current/Scheduler.log | 1 + .../client-policy-state/current/CIAgent.log | 1 + .../current/StateMessage.log | 1 + .../policy/reporting-failure/expected.json | 49 ++++ .../policy/reporting-failure/manifest.json | 12 + .../current/PolicyAgent.log | 1 + .../policy/request-auth-failure/expected.json | 39 ++++ .../policy/request-auth-failure/manifest.json | 10 + .../current/PolicyAgent.log | 1 + .../client-policy-agent/lo/PolicyAgent.lo_ | 1 + .../policy/rotation-split/expected.json | 48 ++++ .../policy/rotation-split/manifest.json | 10 + .../current/PolicyAgent.log | 3 + .../client-policy-agent/current/Scheduler.log | 1 + .../policy/scheduler-deferred/expected.json | 45 ++++ .../policy/scheduler-deferred/manifest.json | 10 + .../issue-321-client-policy-corpus.md | 211 ++++++++++++++++++ 68 files changed, 1160 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-agent/current/Scheduler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-state/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/evidence/client-policy-state/current/StateMessage.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-agent/current/Scheduler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-state/current/StateMessage.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-state/root-a/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/evidence/client-policy-state/root-b/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/download-failure/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/download-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/download-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/evidence/client-policy-agent/current/Scheduler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/evidence/client-policy-state/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/evaluation-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-agent/current/Scheduler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-state/current/StateMessage.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-state/root-a/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/evidence/client-policy-state/root-b/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/gate-c-contradictory/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/evidence/client-policy-agent/current/Scheduler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/malformed/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/malformed/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/malformed/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-agent/current/Scheduler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-state/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/evidence/client-policy-state/current/StateMessage.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/multiline/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/persist-failure/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/persist-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/persist-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-agent/current/Scheduler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-state/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/evidence/client-policy-state/current/StateMessage.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/recovery/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-agent/current/Scheduler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-state/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/evidence/client-policy-state/current/StateMessage.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/reporting-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/request-auth-failure/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/request-auth-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/request-auth-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/rotation-split/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/rotation-split/evidence/client-policy-agent/lo/PolicyAgent.lo_ create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/rotation-split/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/rotation-split/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/scheduler-deferred/evidence/client-policy-agent/current/PolicyAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/scheduler-deferred/evidence/client-policy-agent/current/Scheduler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/scheduler-deferred/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/scheduler-deferred/manifest.json create mode 100644 docs/sccm/preparation/issue-321-client-policy-corpus.md 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/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/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. From de5eca6de8c453958a1fa2c805e8df0b27c92fc1 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:56:27 -0400 Subject: [PATCH 014/422] test(sccm): harden site-core fixture contracts (#347) Part of #327. Canonicalize sitecomp.lo_ and enforce fail-closed site-core artifact storage, coverage, capped-fragment, and capture-state contracts. No production reducer or native acceptance. --- .../lo_/{sitecomp.log.lo_ => sitecomp.lo_} | 0 .../site_core/rotation-boundary/expected.json | 2 +- .../site_core/rotation-boundary/manifest.json | 4 +- .../tests/sccm_site_core_fixture_contract.rs | 324 ++++++++++++++++++ 4 files changed, 327 insertions(+), 3 deletions(-) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/lo_/{sitecomp.log.lo_ => sitecomp.lo_} (100%) create mode 100644 crates/cmtraceopen-parser/tests/sccm_site_core_fixture_contract.rs diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/lo_/sitecomp.log.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/lo_/sitecomp.lo_ similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/lo_/sitecomp.log.lo_ rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/lo_/sitecomp.lo_ diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/expected.json index a931b5d80..851570d25 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/expected.json @@ -42,7 +42,7 @@ "reasonCode": "complete-logical-record-required", "basenames": [ "sitecomp.log", - "sitecomp.log.lo_" + "sitecomp.lo_" ], "rotations": [ "current", diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/manifest.json index 3cb6e1e1b..6c34b7d51 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/manifest.json @@ -39,7 +39,7 @@ "sourceGroup": "server-sitecomp", "sourceKind": "ccmLog", "originalPath": "REDACTED", - "originalBasename": "sitecomp.log.lo_", + "originalBasename": "sitecomp.lo_", "configuredPath": true, "rotation": { "kind": "loUnderscore", @@ -50,7 +50,7 @@ "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T15:01:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/lo_/sitecomp.log.lo_", + "relativePath": "evidence/sccm/server/site-core/sitecomp/lo_/sitecomp.lo_", "bytesCopied": 196 } ] 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..fc0cae2ec --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_site_core_fixture_contract.rs @@ -0,0 +1,324 @@ +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["rotation"]["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"] == "loUnderscore") + .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_basenames = expected["unlinkedObservations"][0]["nextArtifacts"][0]["basenames"] + .as_array() + .expect("rotation request has basenames"); + if requested_basenames + .iter() + .any(|value| value.as_str() == Some("sitecomp.log.lo_")) + || !requested_basenames + .iter() + .any(|value| value.as_str() == Some("sitecomp.lo_")) + { + failures.push(format!( + "rotation-boundary: expected request must use sitecomp.lo_, got {requested_basenames:?}" + )); + } + + 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); + } +} From 781fb9c6acb32a60dfa0073953b98e08417fa6c6 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:58:58 -0400 Subject: [PATCH 015/422] fix(sccm): canonicalize client health fixture contracts (#346) Part of #320. Pin exact LAB site evidence and the canonical unique ccmsetup.lo_ rollover contract with fail-closed adversarial fixture tests. No production reducer or native acceptance. --- .../client/health/contradictory/manifest.json | 2 +- .../health/identity-failure/manifest.json | 2 +- .../client/health/incomplete/manifest.json | 2 +- .../client/health/malformed/manifest.json | 2 +- .../client/health/no-site-or-mp/manifest.json | 2 +- .../lo/{ccmsetup.log.lo_ => ccmsetup.lo_} | 0 .../health/rotation-boundary/manifest.json | 4 +- .../client/health/setup-failure/manifest.json | 2 +- .../current/LocationServices.log | 4 +- .../sccm/client/health/success/manifest.json | 4 +- .../current/LocationServices.log | 4 +- .../health/transport-failure/manifest.json | 4 +- .../sccm_client_health_fixture_contract.rs | 435 ++++++++++++++++++ .../issue-320-client-health-corpus.md | 5 +- 14 files changed, 454 insertions(+), 18 deletions(-) rename crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/evidence/client-ccmsetup/lo/{ccmsetup.log.lo_ => ccmsetup.lo_} (100%) create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_health_fixture_contract.rs 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 index 0cf9bbf3c..27f23e0ef 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/manifest.json @@ -3,7 +3,7 @@ "proposalOnly": true, "syntheticFixture": true, "scenario": "contradictory", - "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "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":"health-contradictory-ccmsetup-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-root-a-health-contradictory-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:50:02Z","bytesCopied":461,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, {"artifactId":"health-contradictory-evaluation-absent","designOnlyCatalog":{"entryId":"client-evaluation","groupMemberships":["client-evaluation"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"CcmEval.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-contradictory-evaluation-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:50:01Z","bytesCopied":0,"relativePath":null}, 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 index 66f45cf0d..e4df8658b 100644 --- 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 @@ -3,7 +3,7 @@ "proposalOnly": true, "syntheticFixture": true, "scenario": "identity-failure", - "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "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":"health-identity-failure-ccmsetup-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-root-a-health-identity-failure-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:20:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, {"artifactId":"health-identity-failure-evaluation-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-root-a-health-identity-failure-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:20:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, 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 index 7b79bf97c..265c6fb56 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/manifest.json @@ -3,7 +3,7 @@ "proposalOnly": true, "syntheticFixture": true, "scenario": "incomplete", - "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "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":"health-incomplete-ccmsetup-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-root-a-health-incomplete-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:20:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, {"artifactId":"health-incomplete-evaluation-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-root-a-health-incomplete-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:20:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/malformed/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/malformed/manifest.json index 00b75f351..e75222659 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/malformed/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/malformed/manifest.json @@ -3,7 +3,7 @@ "proposalOnly": true, "syntheticFixture": true, "scenario": "malformed", - "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "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":"health-malformed-ccmsetup-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-root-a-health-malformed-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:10:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, {"artifactId":"health-malformed-evaluation-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-root-a-health-malformed-evaluation-current","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:10:01Z","bytesCopied":132,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, 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 index 566699f90..2b751c290 100644 --- 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 @@ -3,7 +3,7 @@ "proposalOnly": true, "syntheticFixture": true, "scenario": "no-site-or-mp", - "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "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":"health-no-site-or-mp-ccmsetup-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-root-a-health-no-site-or-mp-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, {"artifactId":"health-no-site-or-mp-evaluation-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-root-a-health-no-site-or-mp-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/evidence/client-ccmsetup/lo/ccmsetup.log.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/evidence/client-ccmsetup/lo/ccmsetup.lo_ similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/evidence/client-ccmsetup/lo/ccmsetup.log.lo_ rename to crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/evidence/client-ccmsetup/lo/ccmsetup.lo_ diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/manifest.json index 763a0ecfb..a72149147 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/manifest.json @@ -3,10 +3,10 @@ "proposalOnly": true, "syntheticFixture": true, "scenario": "rotation-boundary", - "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "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":"health-rotation-boundary-ccmsetup-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-root-a-health-rotation-boundary-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:00:01Z","bytesCopied":124,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, - {"artifactId":"health-rotation-boundary-ccmsetup-lo","designOnlyCatalog":{"entryId":"client-ccmsetup","groupMemberships":["client-ccmsetup"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"ccmsetup.log.lo_","sanitizedSourcePath":"SYNTHETIC://root-a/ccmsetup/Logs/ccmsetup.log.lo_","pathFingerprint":"synthetic-root-a-health-rotation-boundary-ccmsetup-lo","rotation":{"kind":"lo","fragmentComplete":false},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:00:00Z","bytesCopied":118,"relativePath":"evidence/client-ccmsetup/lo/ccmsetup.log.lo_"}, + {"artifactId":"health-rotation-boundary-ccmsetup-lo","designOnlyCatalog":{"entryId":"client-ccmsetup","groupMemberships":["client-ccmsetup"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"ccmsetup.lo_","sanitizedSourcePath":"SYNTHETIC://root-a/ccmsetup/Logs/ccmsetup.lo_","pathFingerprint":"synthetic-root-a-health-rotation-boundary-ccmsetup-lo","rotation":{"kind":"lo","fragmentComplete":false},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:00:00Z","bytesCopied":118,"relativePath":"evidence/client-ccmsetup/lo/ccmsetup.lo_"}, {"artifactId":"health-rotation-boundary-evaluation-absent","designOnlyCatalog":{"entryId":"client-evaluation","groupMemberships":["client-evaluation"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"CcmEval.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-rotation-boundary-evaluation-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:00:02Z","bytesCopied":0,"relativePath":null}, {"artifactId":"health-rotation-boundary-identity-absent","designOnlyCatalog":{"entryId":"client-identity","groupMemberships":["client-identity"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"ClientIDManagerStartup.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-rotation-boundary-identity-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:00:03Z","bytesCopied":0,"relativePath":null}, {"artifactId":"health-rotation-boundary-location-services-absent","designOnlyCatalog":{"entryId":"client-location-services-shared","groupMemberships":["client-content","client-location"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"LocationServices.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-rotation-boundary-location-services-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:00:04Z","bytesCopied":0,"relativePath":null} 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 index 246c2e4fa..12466890e 100644 --- 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 @@ -3,7 +3,7 @@ "proposalOnly": true, "syntheticFixture": true, "scenario": "setup-failure", - "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "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":"health-setup-failure-ccmsetup-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-root-a-health-setup-failure-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:10:00Z","bytesCopied":242,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, {"artifactId":"health-setup-failure-evaluation-absent","designOnlyCatalog":{"entryId":"client-evaluation","groupMemberships":["client-evaluation"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"CcmEval.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-setup-failure-evaluation-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:10:01Z","bytesCopied":0,"relativePath":null}, 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 index 3dcbd96ad..63fb645d2 100644 --- 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 @@ -1,4 +1,4 @@ - - + + 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 index 6fa4d1a26..7042e514e 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/manifest.json @@ -3,11 +3,11 @@ "proposalOnly": true, "syntheticFixture": true, "scenario": "success", - "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "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":"health-success-ccmsetup-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-root-a-health-success-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, {"artifactId":"health-success-evaluation-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-root-a-health-success-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, {"artifactId":"health-success-identity-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-root-a-health-success-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:02Z","bytesCopied":265,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, - {"artifactId":"health-success-location-services-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-root-a-health-success-location-services-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:07Z","bytesCopied":962,"relativePath":"evidence/client-location-services-shared/current/LocationServices.log"} + {"artifactId":"health-success-location-services-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-root-a-health-success-location-services-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:07Z","bytesCopied":954,"relativePath":"evidence/client-location-services-shared/current/LocationServices.log"} ] } 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 index 76a9c62bf..fe19a4c51 100644 --- 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 @@ -1,4 +1,4 @@ - - + + 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 index c7c6942f3..d3d2dd356 100644 --- 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 @@ -3,11 +3,11 @@ "proposalOnly": true, "syntheticFixture": true, "scenario": "transport-failure", - "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "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":"health-transport-failure-ccmsetup-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-root-a-health-transport-failure-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:40:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, {"artifactId":"health-transport-failure-evaluation-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-root-a-health-transport-failure-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:40:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, {"artifactId":"health-transport-failure-identity-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-root-a-health-transport-failure-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:40:02Z","bytesCopied":265,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, - {"artifactId":"health-transport-failure-location-services-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-root-a-health-transport-failure-location-services-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:40:07Z","bytesCopied":966,"relativePath":"evidence/client-location-services-shared/current/LocationServices.log"} + {"artifactId":"health-transport-failure-location-services-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-root-a-health-transport-failure-location-services-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:40:07Z","bytesCopied":958,"relativePath":"evidence/client-location-services-shared/current/LocationServices.log"} ] } diff --git a/crates/cmtraceopen-parser/tests/sccm_client_health_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_health_fixture_contract.rs new file mode 100644 index 000000000..7aec93255 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_health_fixture_contract.rs @@ -0,0 +1,435 @@ +use serde_json::Value; + +fn client_health_root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/client/health") +} + +fn client_health_manifests() -> Vec<(String, Value)> { + let mut scenario_dirs = std::fs::read_dir(client_health_root()) + .expect("client health fixture root is readable") + .map(|entry| { + entry + .expect("client health 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 is_exact_site_code(value: &str) -> bool { + value.len() == 3 + && value + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) +} + +#[derive(Clone)] +struct ClientHealthSiteFactFile { + scenario: String, + relative_path: String, + site_facts: Vec<(usize, String)>, +} + +fn client_health_site_facts() -> Vec { + let root = client_health_root(); + let mut facts = Vec::new(); + + for entry in walkdir(&root) { + let is_evidence = entry + .components() + .any(|component| component.as_os_str() == std::ffi::OsStr::new("evidence")); + if !entry.is_file() || !is_evidence { + continue; + } + + let contents = std::fs::read_to_string(&entry).expect("evidence fixture is UTF-8"); + let site_facts = contents + .lines() + .enumerate() + .flat_map(|(line_index, line)| { + line.split("siteCode=").skip(1).map(move |suffix| { + ( + line_index + 1, + suffix + .chars() + .take_while(|character| character.is_ascii_alphanumeric()) + .collect::(), + ) + }) + }) + .collect::>(); + if site_facts.is_empty() { + continue; + } + + let relative_path = entry + .strip_prefix(&root) + .expect("health evidence is below the fixture root"); + let scenario = relative_path + .components() + .next() + .expect("health evidence has a scenario component") + .as_os_str() + .to_string_lossy() + .into_owned(); + facts.push(ClientHealthSiteFactFile { + scenario, + relative_path: relative_path.to_string_lossy().into_owned(), + site_facts, + }); + } + + facts.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + facts +} + +fn client_health_site_contract_failures( + manifests: &[(String, Value)], + facts: &[ClientHealthSiteFactFile], +) -> Vec { + let mut failures = Vec::new(); + + if manifests.len() != 9 { + failures.push(format!( + "client health must contain exactly 9 manifests, got {}", + manifests.len() + )); + } + for (scenario, manifest) in manifests { + match manifest["bundle"]["siteCode"].as_str() { + Some("LAB") => {} + Some(site_code) => failures.push(format!( + "{scenario}: manifest siteCode must be LAB, got {site_code}" + )), + None => failures.push(format!( + "{scenario}: manifest siteCode must be LAB, got null" + )), + } + } + + let expected = [ + ( + "success", + "success/evidence/client-location-services-shared/current/LocationServices.log", + ), + ( + "transport-failure", + "transport-failure/evidence/client-location-services-shared/current/LocationServices.log", + ), + ]; + if facts.len() != expected.len() { + failures.push(format!( + "client health must contain siteCode facts in exactly 2 evidence files, got {}", + facts.len() + )); + } + + for (scenario, relative_path) in expected { + let matching_files = facts + .iter() + .filter(|fact| fact.relative_path == relative_path) + .collect::>(); + if matching_files.len() != 1 { + failures.push(format!( + "{relative_path}: expected exactly one siteCode evidence file, got {}", + matching_files.len() + )); + continue; + } + + let fact = matching_files[0]; + if fact.scenario != scenario { + failures.push(format!( + "{relative_path}: evidence scenario {} does not match {scenario}", + fact.scenario + )); + } + if fact.site_facts.len() != 2 { + failures.push(format!( + "{relative_path}: expected exactly 2 siteCode facts, got {}", + fact.site_facts.len() + )); + } + + let manifest_site_code = manifests + .iter() + .find(|(manifest_scenario, _)| manifest_scenario == scenario) + .and_then(|(_, manifest)| manifest["bundle"]["siteCode"].as_str()); + match manifest_site_code { + Some(manifest_site_code) => { + for (fact_index, (line_number, site_code)) in fact.site_facts.iter().enumerate() { + let expected_line = fact_index + 1; + if *line_number != expected_line { + failures.push(format!( + "{relative_path}: siteCode fact {} must be on line {expected_line}, got line {line_number}", + fact_index + 1 + )); + } + if site_code != manifest_site_code { + failures.push(format!( + "{relative_path}: evidence siteCode {site_code} does not match manifest siteCode {manifest_site_code}" + )); + } + } + } + None => failures.push(format!( + "{relative_path}: matching manifest siteCode is unavailable" + )), + } + } + + failures +} + +#[test] +fn client_health_site_contract_pins_exact_lab_evidence() { + let manifests = client_health_manifests(); + let facts = client_health_site_facts(); + assert!( + client_health_site_contract_failures(&manifests, &facts).is_empty(), + "canonical client health fixtures satisfy the exact site contract" + ); + + let mut wrong_manifest = manifests.clone(); + wrong_manifest[0].1["bundle"]["siteCode"] = Value::String("ABC".to_owned()); + assert!( + client_health_site_contract_failures(&wrong_manifest, &facts) + .iter() + .any(|failure| failure.contains("siteCode must be LAB")), + "a different valid three-character manifest code must fail closed" + ); + + let mut missing_fact = facts.clone(); + missing_fact[0].site_facts.pop(); + assert!( + client_health_site_contract_failures(&manifests, &missing_fact) + .iter() + .any(|failure| failure.contains("exactly 2 siteCode facts")), + "a missing evidence fact must fail closed" + ); + + let mut mismatched_fact = facts.clone(); + mismatched_fact[0].site_facts[0].1 = "XYZ".to_owned(); + assert!( + client_health_site_contract_failures(&manifests, &mismatched_fact) + .iter() + .any(|failure| failure.contains("does not match manifest siteCode LAB")), + "a different valid evidence code must fail closed" + ); + + let mut moved_fact = facts.clone(); + moved_fact[0].site_facts[0].0 = 3; + assert!( + client_health_site_contract_failures(&manifests, &moved_fact) + .iter() + .any(|failure| failure.contains("must be on line 1")), + "the four evidence facts must remain on the contracted lines" + ); +} + +fn client_health_rotation_contract_failures(manifest: &Value) -> Vec { + const EXPECTED_RELATIVE_PATH: &str = "evidence/client-ccmsetup/lo/ccmsetup.lo_"; + const EXPECTED_SANITIZED_PATH: &str = "SYNTHETIC://root-a/ccmsetup/Logs/ccmsetup.lo_"; + + let mut failures = Vec::new(); + let Some(artifacts) = manifest["artifacts"].as_array() else { + return vec!["rotation-boundary: artifacts must be an array".to_owned()]; + }; + let rollovers = artifacts + .iter() + .filter(|artifact| artifact["rotation"]["kind"] == "lo") + .collect::>(); + if rollovers.len() != 1 { + return vec![format!( + "rotation-boundary: expected exactly one .lo_ artifact, got {}", + rollovers.len() + )]; + } + let rollover = rollovers[0]; + + match rollover["originalBasename"].as_str() { + Some("ccmsetup.lo_") => {} + Some(basename) => failures.push(format!( + "rotation-boundary: originalBasename must equal ccmsetup.lo_, got {basename}" + )), + None => failures.push( + "rotation-boundary: originalBasename must equal ccmsetup.lo_, got null".to_owned(), + ), + } + + let relative_path = rollover["relativePath"].as_str(); + match relative_path { + Some(EXPECTED_RELATIVE_PATH) => {} + Some(relative_path) => failures.push(format!( + "rotation-boundary: relativePath must equal {EXPECTED_RELATIVE_PATH}, got {relative_path}" + )), + None => failures.push(format!( + "rotation-boundary: relativePath must equal {EXPECTED_RELATIVE_PATH}, got null" + )), + } + + match rollover["sanitizedSourcePath"].as_str() { + Some(EXPECTED_SANITIZED_PATH) => {} + Some(sanitized_path) => failures.push(format!( + "rotation-boundary: sanitizedSourcePath must equal {EXPECTED_SANITIZED_PATH}, got {sanitized_path}" + )), + None => failures.push(format!( + "rotation-boundary: sanitizedSourcePath must equal {EXPECTED_SANITIZED_PATH}, got null" + )), + } + + if relative_path == Some(EXPECTED_RELATIVE_PATH) { + let fixture_path = client_health_root() + .join("rotation-boundary") + .join(EXPECTED_RELATIVE_PATH); + if !fixture_path.is_file() { + failures.push(format!( + "rotation-boundary: manifest path 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(); + match rollover["bytesCopied"].as_u64() { + Some(bytes_copied) if bytes_copied == actual_bytes => {} + Some(bytes_copied) => failures.push(format!( + "rotation-boundary: bytesCopied {bytes_copied} does not match fixture length {actual_bytes}" + )), + None => failures.push( + "rotation-boundary: captured rollover must record bytesCopied".to_owned(), + ), + } + } + } + + failures +} + +#[test] +fn client_health_rotation_contract_pins_full_paths() { + let manifests = client_health_manifests(); + let rotations = manifests + .iter() + .find(|(scenario, _)| scenario == "rotation-boundary") + .map(|(_, manifest)| manifest) + .expect("client health has a rotation-boundary scenario"); + assert!( + client_health_rotation_contract_failures(rotations).is_empty(), + "canonical client health rotation fixture satisfies the exact path contract" + ); + + let mut wrong_relative_path = rotations.clone(); + let rollover = wrong_relative_path["artifacts"] + .as_array_mut() + .expect("rotation artifacts are an array") + .iter_mut() + .find(|artifact| artifact["rotation"]["kind"] == "lo") + .expect("rotation corpus has a .lo_ artifact"); + rollover["relativePath"] = Value::String("evidence/other/ccmsetup.lo_".to_owned()); + assert!( + client_health_rotation_contract_failures(&wrong_relative_path) + .iter() + .any(|failure| failure.contains("relativePath must equal")), + "a filename-matching but layout-changing relative path must fail closed" + ); + + let mut wrong_provenance = rotations.clone(); + let rollover = wrong_provenance["artifacts"] + .as_array_mut() + .expect("rotation artifacts are an array") + .iter_mut() + .find(|artifact| artifact["rotation"]["kind"] == "lo") + .expect("rotation corpus has a .lo_ artifact"); + rollover["sanitizedSourcePath"] = + Value::String("SYNTHETIC://different-root/ccmsetup.lo_".to_owned()); + assert!( + client_health_rotation_contract_failures(&wrong_provenance) + .iter() + .any(|failure| failure.contains("sanitizedSourcePath must equal")), + "a filename-matching but provenance-changing source path must fail closed" + ); + + let mut duplicate_rollover = rotations.clone(); + let artifacts = duplicate_rollover["artifacts"] + .as_array_mut() + .expect("rotation artifacts are an array"); + let duplicate = artifacts + .iter() + .find(|artifact| artifact["rotation"]["kind"] == "lo") + .expect("rotation corpus has a .lo_ artifact") + .clone(); + artifacts.push(duplicate); + assert!( + client_health_rotation_contract_failures(&duplicate_rollover) + .iter() + .any(|failure| failure.contains("exactly one .lo_ artifact")), + "a duplicate .lo_ artifact must fail closed" + ); +} + +#[test] +fn client_health_uses_canonical_site_and_rotation_contracts() { + let manifests = client_health_manifests(); + assert_eq!(manifests.len(), 9, "client health scenario matrix changed"); + + let mut failures = Vec::new(); + for (scenario, manifest) in &manifests { + let site_code = manifest["bundle"]["siteCode"] + .as_str() + .expect("client health bundle has a site code"); + if !is_exact_site_code(site_code) { + failures.push(format!( + "{scenario}: siteCode must match ^[A-Z0-9]{{3}}$, got {site_code}" + )); + } + } + failures.extend(client_health_site_contract_failures( + &manifests, + &client_health_site_facts(), + )); + + let rotations = manifests + .iter() + .find(|(scenario, _)| scenario == "rotation-boundary") + .map(|(_, manifest)| manifest) + .expect("client health has a rotation-boundary scenario"); + failures.extend(client_health_rotation_contract_failures(rotations)); + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +fn walkdir(root: &std::path::Path) -> Vec { + 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 +} diff --git a/docs/sccm/preparation/issue-320-client-health-corpus.md b/docs/sccm/preparation/issue-320-client-health-corpus.md index e7f50cdf7..dcac52619 100644 --- a/docs/sccm/preparation/issue-320-client-health-corpus.md +++ b/docs/sccm/preparation/issue-320-client-health-corpus.md @@ -120,8 +120,9 @@ proposed manifests against speculative APIs. ## Privacy and replay rules -- Every identifier is synthetic: `LAB-CLIENT-01`, `CONTOSO`, RFC-style UUIDs, - `.invalid` hosts, `BOOT-TEST-*`, and `REQ-TEST-*` are fixture tokens only. +- 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. From c342e9467f570879506b5e42c37f61e05efe2912 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:18:08 -0400 Subject: [PATCH 016/422] fix(sccm): canonicalize client intake fixture contracts (#344) Refs #319. Pins exact client-intake synthetic identity, rotation provenance, and capped payload contracts after independent and CodeRabbit review. --- .../tests/fixtures/sccm/client/README.md | 12 +- .../client/intake/access-denied/manifest.json | 2 +- .../sccm/client/intake/capped/manifest.json | 2 +- .../client/intake/collision/manifest.json | 2 +- .../current/LocationServices.log | 2 +- .../sccm/client/intake/complete/manifest.json | 4 +- .../client/intake/missing-root/manifest.json | 2 +- .../lo/{AppEnforce.log.lo_ => AppEnforce.lo_} | 0 .../client/intake/rotations/manifest.json | 4 +- .../sccm_client_intake_fixture_contract.rs | 135 ++++++++++++++++++ .../preparation/issue-319-client-intake.md | 10 +- .../2026-07-30-sccm-client-intake-and-core.md | 6 +- 12 files changed, 160 insertions(+), 21 deletions(-) rename crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/lo/{AppEnforce.log.lo_ => AppEnforce.lo_} (100%) diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md index f0467b0b0..7273c0901 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md @@ -7,11 +7,13 @@ compiled test fixtures. Every identity, path, timestamp, byte count, UUID, and log record is deterministic and synthetic. Privacy markers: manifests require `syntheticFixture: true` and -`proposalOnly: true`; evidence files use only `LAB-CLIENT-01`, `CONTOSO`, fake -package/content IDs, or RFC-style test UUIDs; `SYNTHETIC://` is opaque fixture -provenance. 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. +`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` 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 index 9e450ea63..44c7913c4 100644 --- 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 @@ -2,7 +2,7 @@ "sccmManifestVersion": 1, "proposalOnly": true, "syntheticFixture": true, - "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "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/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/manifest.json index 5b4c55907..0f14ca95d 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/manifest.json @@ -2,7 +2,7 @@ "sccmManifestVersion": 1, "proposalOnly": true, "syntheticFixture": true, - "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "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-capped-content-root-a-current","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-content-capped","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:03:00Z","bytesCopied":128,"truncated":true,"relativePath":"evidence/client-content/current/DataTransferService.log"} ] 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 index 2bf5b8044..95e0d7d57 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/manifest.json @@ -5,7 +5,7 @@ "bundle": { "role": "client", "captureHost": "LAB-CLIENT-01", - "siteCode": "CONTOSO", + "siteCode": "LAB", "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" }, 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 index edaecb1ac..e32513a8c 100644 --- 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 @@ -1 +1 @@ - + 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 index 26ca1a181..a2a082536 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/manifest.json @@ -5,7 +5,7 @@ "bundle": { "role": "client", "captureHost": "LAB-CLIENT-01", - "siteCode": "CONTOSO", + "siteCode": "LAB", "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" }, @@ -16,7 +16,7 @@ {"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":172,"relativePath":"evidence/client-location-services-shared/current/LocationServices.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-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-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-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"}, 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 index fa81e77f8..cda178068 100644 --- 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 @@ -2,7 +2,7 @@ "sccmManifestVersion": 1, "proposalOnly": true, "syntheticFixture": true, - "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "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}, diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/lo/AppEnforce.log.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/lo/AppEnforce.lo_ similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/lo/AppEnforce.log.lo_ rename to crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/evidence/client-app-enforce/lo/AppEnforce.lo_ 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 index 78cfd31f9..8bab45780 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/manifest.json @@ -2,10 +2,10 @@ "sccmManifestVersion": 1, "proposalOnly": true, "syntheticFixture": true, - "bundle": {"role":"client","captureHost":"LAB-CLIENT-01","siteCode":"CONTOSO","artifactOrder":"designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId","rotationOrder":"current,lo,numeric-ascending,timestamp-ascending"}, + "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-current","rotation":{"kind":"current","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.log.lo_","sanitizedSourcePath":"SYNTHETIC://root-a/CCM/Logs/AppEnforce.log.lo_","pathFingerprint":"synthetic-root-a-lo","rotation":{"kind":"lo","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T00:01:01Z","bytesCopied":176,"relativePath":"evidence/client-app-enforce/lo/AppEnforce.log.lo_"}, + {"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-lo","rotation":{"kind":"lo","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-b-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-b/CCM/Logs/AppEnforce.log.2","pathFingerprint":"synthetic-root-b-numbered-2","rotation":{"kind":"numbered","number":2,"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/sccm_client_intake_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake_fixture_contract.rs index df5df66ba..bd4fe5783 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_intake_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake_fixture_contract.rs @@ -2,14 +2,34 @@ 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/docs/sccm/preparation/issue-319-client-intake.md b/docs/sccm/preparation/issue-319-client-intake.md index 5fb0dba87..56da60a46 100644 --- a/docs/sccm/preparation/issue-319-client-intake.md +++ b/docs/sccm/preparation/issue-319-client-intake.md @@ -68,7 +68,7 @@ than being guessed. "bundle": { "role": "client", "captureHost": "LAB-CLIENT-01", - "siteCode": "CONTOSO", + "siteCode": "LAB", "artifactOrder": "designOnlyCatalog.entryId,pathFingerprint,rotationRank,originalBasename,artifactId", "rotationOrder": "current,lo,numeric-ascending,timestamp-ascending" }, @@ -150,7 +150,8 @@ filesystem access, globbing in the pure crate, or redefinition of CCM. - `.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 `.log.lo_` and `.log.N` spellings. + 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. @@ -186,8 +187,9 @@ separate acceptance gate. ## Fixture privacy and sanitization -- Fixtures use only `LAB-CLIENT-01`, `CONTOSO`, RFC-style test UUIDs, and fake - `APP-TEST-001`, `CONTENT-TEST-001`, and `KB0000000` tokens. +- 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. 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 index 4dca3df7f..b772ab38c 100644 --- 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 @@ -19,7 +19,7 @@ - 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`, `CONTOSO`, RFC-style UUIDs, fake package/content IDs, and no customer paths, users, SIDs, tokens, certificates, tenant IDs, serials, or real deployment names. +- 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. --- @@ -217,7 +217,7 @@ Paths for candidate discovery are platform/native concerns. Current client opera 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.log.lo_`, and `AppEnforce.log.2` map to one logical client-app-enforce group with three ordered fragments and no filename collision. + - `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. @@ -362,7 +362,7 @@ The writer must use a dedicated file such as `sccm-manifest.json` or an additive ```text evidence/sccm/client/client-app-enforce/current/AppEnforce.log -evidence/sccm/client/client-app-enforce/lo/AppEnforce.log.lo_ +evidence/sccm/client/client-app-enforce/lo/AppEnforce.lo_ evidence/sccm/client/client-app-enforce/numbered-2/AppEnforce.log.2 ``` From c8119325f07b763e5d098bb8e5cebc41342b8ec4 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:26:16 -0400 Subject: [PATCH 017/422] feat(sccm): retain diagnostic signal tokens (#348) Refs #318. Adds the reviewed Task 5 structured signal contract with parsed-base-safe enrichment, deterministic UTF-16 spans, and serde round-trip coverage. --- crates/cmtraceopen-parser/src/sccm/mod.rs | 2 + crates/cmtraceopen-parser/src/sccm/signals.rs | 135 ++++++++++++++++ .../tests/sccm_spine_contract.rs | 149 +++++++++++++++++- 3 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/signals.rs diff --git a/crates/cmtraceopen-parser/src/sccm/mod.rs b/crates/cmtraceopen-parser/src/sccm/mod.rs index 6b82b5889..bc79d4840 100644 --- a/crates/cmtraceopen-parser/src/sccm/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/mod.rs @@ -3,7 +3,9 @@ mod evidence; mod ingest; pub mod models; mod rotation; +mod signals; pub use catalog::*; pub use ingest::*; pub use models::*; +pub use signals::*; 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/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 99c43540d..f39ee8971 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -1,9 +1,10 @@ use cmtraceopen_parser::models::log_entry::{LogFormat, ParserKind}; use cmtraceopen_parser::parser::detect::detect_parser; use cmtraceopen_parser::sccm::{ - classify_artifact_name, declared_source_catalog, normalize_ccm_artifact, SccmArtifact, - SccmArtifactFamily, SccmCoverageState, SccmFindingClass, SccmRole, SccmRotation, - SccmTimeOrderingState, SccmUnknownRotation, SCCM_DIAGNOSTICS_SCHEMA_VERSION, + classify_artifact_name, declared_source_catalog, extract_signals, normalize_ccm_artifact, + SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmFindingClass, SccmRole, SccmRotation, + SccmSignal, SccmSignalKind, SccmTimeOrderingState, SccmUnknownRotation, + SCCM_DIAGNOSTICS_SCHEMA_VERSION, }; fn client_policy_artifact() -> SccmArtifact { @@ -105,6 +106,148 @@ fn public_ccm_single_line_projection_matches_line_parser() { ); } +#[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 public_ccm_malformed_continuation_stays_plain() { let text = " Date: Thu, 30 Jul 2026 18:54:26 -0400 Subject: [PATCH 018/422] test(sccm): prepare client deployment corpus (#349) Refs #322. Adds the reviewed 12-scenario synthetic deployment/content corpus and fail-closed fixture contracts; production reduction and native validation remain open. --- .../current/AppIntentEval.log | 2 + .../evidence/client-content/current/CAS.log | 1 + .../current/DataTransferService.log | 2 + .../bits-transfer-failure/expected.json | 26 + .../bits-transfer-failure/manifest.json | 12 + .../current/AppIntentEval.log | 2 + .../evidence/client-content/current/CAS.log | 2 + .../current/DataTransferService.log | 2 + .../deployment/cache-failure/expected.json | 26 + .../deployment/cache-failure/manifest.json | 12 + .../current/AppIntentEval.log | 3 + .../dependency-failure/expected.json | 29 + .../dependency-failure/manifest.json | 10 + .../client-app-enforce/current/AppEnforce.log | 1 + .../current/AppDiscovery.log | 1 + .../current/AppIntentEval.log | 2 + .../evidence/client-content/current/CAS.log | 2 + .../current/DataTransferService.log | 2 + .../detection-false-negative/expected.json | 28 + .../detection-false-negative/manifest.json | 14 + .../current/AppIntentEval.log | 2 + .../evidence/client-content/current/CAS.log | 1 + .../dp-content-missing/expected.json | 35 + .../dp-content-missing/manifest.json | 11 + .../client-app-enforce/current/AppEnforce.log | 1 + .../current/AppIntentEval.log | 2 + .../evidence/client-content/current/CAS.log | 2 + .../current/DataTransferService.log | 2 + .../current/InstallerSupplemental.log | 1 + .../deployment/enforcement-exit/expected.json | 28 + .../deployment/enforcement-exit/manifest.json | 14 + .../client-app-enforce/current/AppEnforce.log | 1 + .../current/AppIntentEval.log | 2 + .../deployment/incomplete/expected.json | 23 + .../deployment/incomplete/manifest.json | 12 + .../current/AppIntentEval.log | 2 + .../deployment/location-missing/expected.json | 29 + .../deployment/location-missing/manifest.json | 11 + .../current/AppIntentEval.log | 1 + .../deployment/not-targeted/expected.json | 29 + .../deployment/not-targeted/manifest.json | 10 + .../current/AppIntentEval.log | 2 + .../requirements-failure/expected.json | 29 + .../requirements-failure/manifest.json | 10 + .../current/AppIntentEval.log | 2 + .../evidence/client-content/current/CAS.log | 1 + .../evidence/client-content/lo/CAS.lo_ | 1 + .../rotation-boundary/expected.json | 30 + .../rotation-boundary/manifest.json | 12 + .../client-app-enforce/current/AppEnforce.log | 1 + .../current/AppDiscovery.log | 1 + .../current/AppIntentEval.log | 2 + .../evidence/client-content/current/CAS.log | 2 + .../current/DataTransferService.log | 2 + .../current/StateMessage.log | 1 + .../client/deployment/success/expected.json | 43 + .../client/deployment/success/manifest.json | 16 + ...sccm_client_deployment_fixture_contract.rs | 1490 +++++++++++++++++ .../issue-322-client-deployment-corpus.md | 184 ++ 59 files changed, 2227 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/evidence/client-app-intent/current/AppIntentEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/evidence/client-content/current/CAS.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/evidence/client-content/current/DataTransferService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/bits-transfer-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/evidence/client-app-intent/current/AppIntentEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/evidence/client-content/current/CAS.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/evidence/client-content/current/DataTransferService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/cache-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dependency-failure/evidence/client-app-intent/current/AppIntentEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dependency-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dependency-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-app-enforce/current/AppEnforce.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-app-intent/current/AppDiscovery.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-app-intent/current/AppIntentEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-content/current/CAS.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/evidence/client-content/current/DataTransferService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/detection-false-negative/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/evidence/client-app-intent/current/AppIntentEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/evidence/client-content/current/CAS.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/dp-content-missing/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-app-enforce/current/AppEnforce.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-app-intent/current/AppIntentEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-content/current/CAS.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-content/current/DataTransferService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/evidence/client-installer-supplemental/current/InstallerSupplemental.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/evidence/client-app-enforce/current/AppEnforce.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/evidence/client-app-intent/current/AppIntentEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/location-missing/evidence/client-app-intent/current/AppIntentEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/location-missing/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/location-missing/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/not-targeted/evidence/client-app-intent/current/AppIntentEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/not-targeted/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/not-targeted/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/requirements-failure/evidence/client-app-intent/current/AppIntentEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/requirements-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/requirements-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/evidence/client-app-intent/current/AppIntentEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/evidence/client-content/current/CAS.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/evidence/client-content/lo/CAS.lo_ create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-app-enforce/current/AppEnforce.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-app-intent/current/AppDiscovery.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-app-intent/current/AppIntentEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-content/current/CAS.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-content/current/DataTransferService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/evidence/client-policy-state/current/StateMessage.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_deployment_fixture_contract.rs create mode 100644 docs/sccm/preparation/issue-322-client-deployment-corpus.md 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..97ab0f670 --- /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": "insufficientEvidence", + "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..e2afe8804 --- /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"},{"logicalArtifactId":"client-installer-supplemental","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":[{"observationId":"supplemental:installer:enforcement-exit","artifactId":"deployment-enforcement-exit-supplemental-current","completeLogicalRecord":true,"keyConfidence":"none","confidenceCeiling":"low","correlationEligible":false,"reason":"unvalidated supplemental text at a similar time cannot override the exact AppEnforce transaction","evidence":{"artifactId":"deployment-enforcement-exit-supplemental-current","startLine":1,"endLine":1}}], + "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..129f26f39 --- /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":[{"observationId":"fragment:deployment-incomplete-enforce-capped","artifactId":"deployment-incomplete-enforce-capped","completeLogicalRecord":false,"keyConfidence":"none","confidenceCeiling":"low","correlationEligible":false,"reason":"capped enforcement bytes do not form a logical record and cannot attach by time","evidence":{"artifactId":"deployment-incomplete-enforce-capped","startLine":1,"endLine":1}}], + "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/sccm_client_deployment_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment_fixture_contract.rs new file mode 100644 index 000000000..db88db724 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment_fixture_contract.rs @@ -0,0 +1,1490 @@ +use cmtraceopen_parser::models::log_entry::LogFormat; +use regex::Regex; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path, PathBuf}; + +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 STATE_CHAIN: [&str; 8] = [ + "intent", + "requirements", + "locateContent", + "transfer", + "cache", + "enforce", + "detect", + "report", +]; + +const DOCUMENTED_CORPUS_DIGEST: &str = + "27e0f8b6fab7bc584902718229824a45bdab9d1c9c78601e04f9d571e34c5c53"; + +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, + capture_states: BTreeMap, + digest: String, +} + +fn deployment_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/client/deployment") +} + +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 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 scenario_names() { + let scenario_root = deployment_root().join(&scenario); + let manifest = load_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; + 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: SCENARIOS.len(), + artifacts, + evidence_files, + evidence_bytes, + capture_states, + digest: hex_digest(&sha256(digest_rows.concat().as_bytes())), + } +} + +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 scenario_names() -> Vec { + let mut scenarios = std::fs::read_dir(deployment_root()) + .expect("deployment fixture root exists") + .map(|entry| { + entry + .expect("deployment 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 +} + +#[test] +fn missing_evidence_root_is_an_empty_corpus() { + let missing = deployment_root().join("__missing_evidence_root__"); + assert!(!missing.exists(), "test sentinel must stay absent"); + assert!( + walk_files(&missing).is_empty(), + "an all-noncapture scenario has no physical evidence directory" + ); +} + +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 json_string_array(value: &Value) -> Vec { + value + .as_array() + .expect("value is an array") + .iter() + .map(|item| item.as_str().expect("array item is 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 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 key_needle(field: &str, value: &Value) -> Result, String> { + if matches!( + field, + "keyProfileKind" | "confidence" | "extractionProfileId" + ) { + return Ok(None); + } + if let Some(value) = value.as_str() { + return Ok(Some(format!("{field}={value}"))); + } + if value.is_number() { + return Ok(Some(format!("{field}={value}"))); + } + Err(format!("transaction key field {field} is not scalar")) +} + +fn validate_semantic_contract( + scenario: &str, + scenario_root: &Path, + manifest: &Value, + expected: &Value, +) -> Result<(), String> { + let artifacts = manifest["artifacts"] + .as_array() + .ok_or_else(|| "manifest artifacts are not an array".to_owned())?; + let mut artifacts_by_id = BTreeMap::new(); + let mut artifact_paths = BTreeMap::new(); + let mut physical_by_logical = BTreeMap::>::new(); + + for artifact in artifacts { + let artifact_id = artifact["artifactId"] + .as_str() + .ok_or_else(|| "artifactId is not a string".to_owned())?; + if artifacts_by_id.insert(artifact_id, artifact).is_some() { + return Err(format!("duplicate artifactId {artifact_id}")); + } + artifact_effective_state(artifact)?; + let logical_id = artifact["designOnlyCatalog"]["entryId"] + .as_str() + .ok_or_else(|| format!("{artifact_id} has no design-only logical ID"))?; + physical_by_logical + .entry(logical_id.to_owned()) + .or_default() + .push(artifact); + + if let Some(relative_path) = artifact["relativePath"].as_str() { + if let Some(previous) = artifact_paths.insert(relative_path, artifact_id) { + return Err(format!( + "duplicate evidence path {relative_path} aliases {previous} and {artifact_id}" + )); + } + } + } + + let mut derived_coverage = BTreeMap::new(); + let mut derived_partial_ids = BTreeMap::>::new(); + for (logical_id, physical) in &physical_by_logical { + let states = physical + .iter() + .map(|artifact| artifact_effective_state(artifact)) + .collect::, _>>()?; + let state = combine_coverage_states(&states)?; + if state == "partial" { + let mut artifact_ids = physical + .iter() + .filter_map(|artifact| { + (artifact_effective_state(artifact).ok().as_deref() == Some("partial")) + .then(|| artifact["artifactId"].as_str().map(str::to_owned)) + .flatten() + }) + .collect::>(); + artifact_ids.sort(); + derived_partial_ids.insert(logical_id.clone(), artifact_ids); + } + derived_coverage.insert(logical_id.clone(), state); + } + + 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}")); + } + if state == "partial" { + let mut declared_ids = json_string_array(&coverage["artifactIds"]); + declared_ids.sort(); + let expected_ids = derived_partial_ids.get(logical_id).ok_or_else(|| { + format!("{logical_id} declares partial without partial artifacts") + })?; + if &declared_ids != expected_ids { + return Err(format!( + "{logical_id} partial coverage artifact IDs {declared_ids:?} != {expected_ids:?}" + )); + } + } + } + if declared_coverage != derived_coverage { + return Err(format!( + "coverage mismatch: declared {declared_coverage:?}, derived {derived_coverage:?}" + )); + } + + for transaction in expected["transactions"] + .as_array() + .ok_or_else(|| "transactions are not an array".to_owned())? + { + let transaction_id = transaction["transactionId"] + .as_str() + .ok_or_else(|| "transactionId is not a string".to_owned())?; + let evidence_refs = transaction["evidence"] + .as_array() + .ok_or_else(|| format!("{transaction_id} evidence is not an array"))?; + let mut cited_text = String::new(); + for evidence_ref in evidence_refs { + cited_text.push_str(&evidence_text( + scenario_root, + &artifacts_by_id, + evidence_ref, + )?); + cited_text.push('\n'); + } + for (field, value) in transaction["key"] + .as_object() + .ok_or_else(|| format!("{transaction_id} key is not an object"))? + { + let Some(needle) = key_needle(field, value)? else { + continue; + }; + if !cited_text.contains(&needle) { + return Err(format!( + "{transaction_id} key {field} is not bound to cited evidence ({needle})" + )); + } + } + + for artifact_id in json_string_array(&transaction["coverageGapArtifactIds"]) { + let artifact = artifacts_by_id + .get(artifact_id.as_str()) + .ok_or_else(|| format!("{transaction_id} coverage gap references {artifact_id}"))?; + if artifact_effective_state(artifact)? == "captured" { + return Err(format!( + "{transaction_id} coverage gap {artifact_id} is complete/captured" + )); + } + } + + let fact = &transaction["counterpartReadyFact"]; + if !fact.is_null() { + let fact_evidence = &fact["evidence"]; + let fact_text = evidence_text(scenario_root, &artifacts_by_id, fact_evidence)?; + for field in [ + "packageId", + "contentId", + "contentVersion", + "distributionPointHostHandle", + "requestId", + ] { + let needle = key_needle(field, &fact[field])? + .ok_or_else(|| format!("counterpart key {field} is metadata"))?; + if !fact_text.contains(&needle) { + return Err(format!( + "{transaction_id} counterpart {field} is not bound to cited evidence" + )); + } + } + + let timestamp = &fact["timestampProvenance"]; + let normalized = timestamp["normalizedUtc"] + .as_str() + .ok_or_else(|| format!("{transaction_id} counterpart timestamp is missing"))?; + let expected_millis = chrono::DateTime::parse_from_rfc3339(normalized) + .map_err(|error| format!("{transaction_id} counterpart timestamp: {error}"))? + .timestamp_millis(); + let expected_offset = timestamp["offsetMinutes"] + .as_i64() + .ok_or_else(|| format!("{transaction_id} counterpart offset is missing"))?; + let artifact_id = fact_evidence["artifactId"] + .as_str() + .ok_or_else(|| format!("{transaction_id} counterpart artifact is missing"))?; + let (entries, errors) = + cmtraceopen_parser::parser::ccm::parse_content(&fact_text, artifact_id, None); + let ccm_entries = entries + .iter() + .filter(|entry| entry.format == LogFormat::Ccm) + .collect::>(); + if errors != 0 || ccm_entries.len() != 1 { + return Err(format!( + "{transaction_id} counterpart citation is not one complete CCM record" + )); + } + let entry = ccm_entries[0]; + if entry.timestamp != Some(expected_millis) + || entry.timezone_offset.map(i64::from) != Some(expected_offset) + { + return Err(format!( + "{transaction_id} counterpart timestamp/offset is not bound to cited evidence" + )); + } + } + } + + let incomplete_physical_ids = artifacts + .iter() + .filter(|artifact| { + artifact["relativePath"].is_string() + && artifact["rotation"]["fragmentComplete"] == false + }) + .filter_map(|artifact| artifact["artifactId"].as_str().map(str::to_owned)) + .collect::>(); + let mut incomplete_observation_ids = BTreeSet::new(); + + for observation in expected["sourceLocalObservations"] + .as_array() + .ok_or_else(|| "sourceLocalObservations is not an array".to_owned())? + { + let observation_id = observation["observationId"] + .as_str() + .ok_or_else(|| "source-local observation has no ID".to_owned())?; + let key_confidence = observation["keyConfidence"] + .as_str() + .ok_or_else(|| format!("{observation_id} has no keyConfidence"))?; + let ceiling = observation["confidenceCeiling"] + .as_str() + .ok_or_else(|| format!("{observation_id} has no confidenceCeiling"))?; + let correlation_eligible = observation["correlationEligible"] + .as_bool() + .ok_or_else(|| format!("{observation_id} has no correlationEligible flag"))?; + if !matches!(key_confidence, "none" | "candidate") + || ceiling != "low" + || correlation_eligible + || observation["confidence"] + .as_str() + .is_some_and(|confidence| confidence != "low") + { + return Err(format!( + "source-local observation {observation_id} must stay Low and non-correlatable" + )); + } + + if observation["completeLogicalRecord"] == false { + let artifact_id = observation["artifactId"] + .as_str() + .ok_or_else(|| format!("{observation_id} has no artifactId"))?; + if observation["evidence"]["artifactId"] != artifact_id { + return Err(format!( + "source-local observation {observation_id} cites a different artifact" + )); + } + let artifact = artifacts_by_id + .get(artifact_id) + .ok_or_else(|| format!("{observation_id} references {artifact_id}"))?; + if artifact["rotation"]["fragmentComplete"] != false { + return Err(format!( + "source-local observation {observation_id} is incomplete but its artifact is complete" + )); + } + if !incomplete_observation_ids.insert(artifact_id.to_owned()) { + return Err(format!( + "source-local observation artifact {artifact_id} is duplicated" + )); + } + } + } + if incomplete_observation_ids != incomplete_physical_ids { + return Err(format!( + "incomplete source-local observations {incomplete_observation_ids:?} do not match physical fragments {incomplete_physical_ids:?}" + )); + } + + if expected["scenario"] != scenario { + return Err(format!( + "expected scenario {} does not match {scenario}", + expected["scenario"] + )); + } + Ok(()) +} + +#[test] +fn deployment_corpus_inventory_and_path_artifact_digest_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(), 2); + capture_states.insert("accessDenied".to_owned(), 1); + capture_states.insert("capped".to_owned(), 1); + capture_states.insert("captured".to_owned(), 32); + assert_eq!( + corpus_inventory(), + CorpusInventory { + scenarios: 12, + artifacts: 36, + evidence_files: 33, + evidence_bytes: 16_840, + capture_states, + digest: DOCUMENTED_CORPUS_DIGEST.to_owned(), + } + ); +} + +#[test] +fn ccm_records_and_physical_rotation_boundaries_are_pinned() { + for scenario in SCENARIOS { + let scenario_root = deployment_root().join(scenario); + let manifest = load_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; + }; + if artifact["kind"] != "ccmLog" { + continue; + } + + let content = std::fs::read_to_string(scenario_root.join(relative_path)) + .expect("CCM evidence is UTF-8"); + let (entries, errors) = + cmtraceopen_parser::parser::ccm::parse_content(&content, relative_path, None); + let complete = artifact["rotation"]["fragmentComplete"] == true; + if complete { + assert_eq!( + errors, 0, + "{scenario}/{relative_path}: complete CCM parse errors" + ); + assert!( + !entries.is_empty() + && entries.iter().all(|entry| entry.format == LogFormat::Ccm), + "{scenario}/{relative_path}: complete evidence must contain only logical CCM records" + ); + } else { + assert!( + entries.iter().all(|entry| entry.format != LogFormat::Ccm), + "{scenario}/{relative_path}: incomplete physical evidence formed a CCM record" + ); + } + } + } + + let rotation_root = deployment_root().join("rotation-boundary"); + let manifest = load_json(&rotation_root.join("manifest.json")); + let artifacts = manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array"); + let current = artifacts + .iter() + .find(|artifact| artifact["artifactId"] == "deployment-rotation-boundary-content-current") + .expect("rotation current artifact"); + let archived = artifacts + .iter() + .find(|artifact| artifact["artifactId"] == "deployment-rotation-boundary-content-lo") + .expect("rotation archived artifact"); + + assert_eq!(current["originalBasename"], "CAS.log"); + assert_eq!(current["rotation"]["kind"], "current"); + assert_eq!(current["rotation"]["fragmentComplete"], false); + assert_eq!(archived["originalBasename"], "CAS.lo_"); + assert_eq!(archived["rotation"]["kind"], "lo"); + assert_eq!(archived["rotation"]["fragmentComplete"], false); + assert_eq!(current["pathFingerprint"], archived["pathFingerprint"]); + assert_ne!(current["relativePath"], archived["relativePath"]); + + let archived_content = std::fs::read_to_string( + rotation_root.join( + archived["relativePath"] + .as_str() + .expect("archived relative path"), + ), + ) + .expect("archived rotation fixture"); + let current_content = std::fs::read_to_string( + rotation_root.join( + current["relativePath"] + .as_str() + .expect("current relative path"), + ), + ) + .expect("current rotation fixture"); + let combined = format!("{archived_content}{current_content}"); + let (combined_entries, combined_errors) = cmtraceopen_parser::parser::ccm::parse_content( + &combined, + "CAS.combined-for-test.log", + None, + ); + assert_eq!(combined_errors, 0, "controlled join forms one CCM record"); + assert_eq!(combined_entries.len(), 1, "controlled join record count"); + assert_eq!(combined_entries[0].format, LogFormat::Ccm); +} + +#[test] +fn manifest_coverage_citations_and_observation_ceilings_are_bound() { + for scenario in SCENARIOS { + let scenario_root = deployment_root().join(scenario); + let manifest = load_json(&scenario_root.join("manifest.json")); + let expected = load_json(&scenario_root.join("expected.json")); + validate_semantic_contract(scenario, &scenario_root, &manifest, &expected) + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + } +} + +#[test] +fn adversarial_self_declared_contract_mutations_fail_closed() { + let scenario_root = deployment_root().join("location-missing"); + let manifest = load_json(&scenario_root.join("manifest.json")); + let mut expected = load_json(&scenario_root.join("expected.json")); + expected["coverage"][1]["state"] = Value::String("captured".to_owned()); + let error = + validate_semantic_contract("location-missing", &scenario_root, &manifest, &expected) + .expect_err("absent manifest coverage cannot be self-declared captured"); + assert!(error.contains("coverage"), "{error}"); + + let scenario_root = deployment_root().join("success"); + let manifest = load_json(&scenario_root.join("manifest.json")); + let mut expected = load_json(&scenario_root.join("expected.json")); + expected["transactions"][0]["key"]["packageId"] = Value::String("LAB99999".to_owned()); + let error = validate_semantic_contract("success", &scenario_root, &manifest, &expected) + .expect_err("a transaction key must be present in its cited evidence"); + assert!(error.contains("packageId"), "{error}"); + + let mut expected = load_json(&scenario_root.join("expected.json")); + expected["transactions"][0]["counterpartReadyFact"]["timestampProvenance"]["normalizedUtc"] = + Value::String("2026-07-30T03:00:03Z".to_owned()); + let error = validate_semantic_contract("success", &scenario_root, &manifest, &expected) + .expect_err("counterpart timestamp must bind to the cited CCM record"); + assert!(error.contains("timestamp"), "{error}"); + + let mut duplicate_path_manifest = manifest.clone(); + duplicate_path_manifest["artifacts"][1]["relativePath"] = + duplicate_path_manifest["artifacts"][0]["relativePath"].clone(); + let expected = load_json(&scenario_root.join("expected.json")); + let error = validate_semantic_contract( + "success", + &scenario_root, + &duplicate_path_manifest, + &expected, + ) + .expect_err("two artifact IDs cannot alias one evidence path"); + assert!(error.contains("duplicate evidence path"), "{error}"); + + let scenario_root = deployment_root().join("rotation-boundary"); + let manifest = load_json(&scenario_root.join("manifest.json")); + let mut expected = load_json(&scenario_root.join("expected.json")); + expected["sourceLocalObservations"][0]["keyConfidence"] = Value::String("exact".to_owned()); + let error = + validate_semantic_contract("rotation-boundary", &scenario_root, &manifest, &expected) + .expect_err("incomplete candidates cannot claim exact keys"); + assert!(error.contains("source-local observation"), "{error}"); + + let mut expected = load_json(&scenario_root.join("expected.json")); + expected["sourceLocalObservations"][0]["confidenceCeiling"] = Value::String("high".to_owned()); + let error = + validate_semantic_contract("rotation-boundary", &scenario_root, &manifest, &expected) + .expect_err("incomplete candidates must stay low"); + assert!(error.contains("source-local observation"), "{error}"); + + let mut expected = load_json(&scenario_root.join("expected.json")); + expected["sourceLocalObservations"][0]["correlationEligible"] = Value::Bool(true); + let error = + validate_semantic_contract("rotation-boundary", &scenario_root, &manifest, &expected) + .expect_err("incomplete candidates must stay non-correlatable"); + assert!(error.contains("source-local observation"), "{error}"); +} + +#[test] +fn deployment_fixture_matrix_is_exact_safe_and_deterministic() { + assert_eq!( + scenario_names(), + SCENARIOS.map(str::to_owned), + "the #322 scenario matrix changed" + ); + + let privacy_patterns = [ + ( + "user profile path", + Regex::new(r"(?i)[A-Z]:\\Users\\").expect("user path regex"), + ), + ( + "Windows SID", + Regex::new(r"\bS-1-\d+(?:-\d+){2,}\b").expect("SID regex"), + ), + ( + "email address", + Regex::new(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b").expect("email regex"), + ), + ]; + + for scenario in SCENARIOS { + let scenario_root = deployment_root().join(scenario); + let manifest = load_json(&scenario_root.join("manifest.json")); + let expected = load_json(&scenario_root.join("expected.json")); + + assert_eq!( + manifest["scenario"], scenario, + "{scenario}: manifest scenario" + ); + assert_eq!( + manifest["proposalOnly"], true, + "{scenario}: proposal boundary" + ); + assert_eq!( + manifest["syntheticFixture"], true, + "{scenario}: synthetic boundary" + ); + assert_eq!(manifest["bundle"]["role"], "client", "{scenario}: role"); + assert_eq!( + manifest["bundle"]["workflow"], "deployment", + "{scenario}: workflow" + ); + assert_eq!( + manifest["bundle"]["siteCode"], "LAB", + "{scenario}: exact synthetic site code" + ); + + assert_eq!( + expected["contractState"], "proposedPending318And319", + "{scenario}: dependency boundary" + ); + assert_eq!(expected["workflow"], "deployment", "{scenario}: workflow"); + assert_eq!( + expected["scenario"], scenario, + "{scenario}: expected scenario" + ); + assert_eq!( + json_string_array(&expected["stateChain"]), + STATE_CHAIN.map(str::to_owned), + "{scenario}: phase chain" + ); + assert_eq!( + expected["analysisContract"]["independentReducer"], true, + "{scenario}: independent reducer" + ); + assert_eq!( + expected["analysisContract"]["consumesPolicyReducerOutput"], false, + "{scenario}: deployment must not consume policy output" + ); + assert_eq!( + expected["analysisContract"]["policyCoverageRequired"], false, + "{scenario}: missing policy coverage must not block deployment facts" + ); + assert_eq!( + expected["analysisContract"]["crossSideCorrelationPerformed"], false, + "{scenario}: no cross-side correlation" + ); + assert_eq!( + expected["reorderedInputDeterministic"], true, + "{scenario}: deterministic input reordering contract" + ); + + let artifacts = manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array"); + let mut artifacts_by_id = BTreeMap::new(); + let mut referenced_files = BTreeSet::new(); + + for artifact in artifacts { + let artifact_id = artifact["artifactId"] + .as_str() + .expect("artifactId is a string"); + assert!( + artifacts_by_id.insert(artifact_id, artifact).is_none(), + "{scenario}: duplicate artifactId {artifact_id}" + ); + assert_eq!(artifact["role"], "client", "{scenario}/{artifact_id}: role"); + + let state = artifact["captureState"] + .as_str() + .expect("captureState is a string"); + let captured = matches!(state, "captured" | "capped"); + if captured { + assert_eq!( + artifact["encoding"], "utf-8", + "{scenario}/{artifact_id}: encoding" + ); + let relative_path = artifact["relativePath"] + .as_str() + .expect("captured artifact has a relativePath"); + let relative = Path::new(relative_path); + assert!( + !relative.is_absolute() + && relative + .components() + .all(|component| matches!(component, Component::Normal(_))), + "{scenario}/{artifact_id}: unsafe relativePath {relative_path}" + ); + assert_eq!( + relative.components().next(), + Some(Component::Normal(std::ffi::OsStr::new("evidence"))), + "{scenario}/{artifact_id}: evidence path root" + ); + + let fixture_path = scenario_root.join(relative); + assert!( + fixture_path.is_file(), + "{scenario}/{artifact_id}: missing {}", + fixture_path.display() + ); + let actual_bytes = std::fs::metadata(&fixture_path) + .expect("evidence metadata is readable") + .len(); + assert_eq!( + artifact["bytesCopied"].as_u64(), + Some(actual_bytes), + "{scenario}/{artifact_id}: exact bytes" + ); + let contents = + std::fs::read_to_string(&fixture_path).expect("evidence fixture is UTF-8"); + if artifact["rotation"]["fragmentComplete"] == true { + assert!( + contents.contains("SYNTHETIC FIXTURE"), + "{scenario}/{artifact_id}: complete evidence needs a marker" + ); + } + let canonical_path = fixture_path + .canonicalize() + .expect("evidence fixture canonicalizes"); + assert!( + referenced_files.insert(canonical_path), + "{scenario}/{artifact_id}: duplicate canonical evidence path" + ); + } else { + assert_eq!( + artifact["bytesCopied"], 0, + "{scenario}/{artifact_id}: noncapture bytes" + ); + assert!( + artifact["relativePath"].is_null(), + "{scenario}/{artifact_id}: noncapture relativePath" + ); + assert!( + artifact["encoding"].is_null(), + "{scenario}/{artifact_id}: noncapture encoding" + ); + assert!( + artifact["collectionLimit"].is_null(), + "{scenario}/{artifact_id}: noncapture collectionLimit" + ); + } + } + + let evidence_files = walk_files(&scenario_root.join("evidence")) + .into_iter() + .map(|path| path.canonicalize().expect("evidence path canonicalizes")) + .collect::>(); + assert_eq!( + evidence_files, referenced_files, + "{scenario}: evidence files must be referenced exactly once" + ); + + let provenance = expected["artifactProvenance"] + .as_array() + .expect("artifactProvenance is an array"); + let provenance_ids = sorted_ids(&expected["artifactProvenance"], "artifactId"); + let mut sorted_provenance_ids = provenance_ids.clone(); + sorted_provenance_ids.sort(); + assert_eq!( + provenance_ids, sorted_provenance_ids, + "{scenario}: provenance order" + ); + let mut physical_evidence_ids = artifacts + .iter() + .filter(|artifact| artifact["relativePath"].is_string()) + .map(|artifact| { + artifact["artifactId"] + .as_str() + .expect("artifactId is a string") + .to_owned() + }) + .collect::>(); + physical_evidence_ids.sort(); + assert_eq!( + provenance_ids, physical_evidence_ids, + "{scenario}: provenance must cover every physical evidence artifact exactly once" + ); + for item in provenance { + let artifact_id = item["artifactId"] + .as_str() + .expect("provenance artifactId is a string"); + let artifact = artifacts_by_id + .get(artifact_id) + .unwrap_or_else(|| panic!("{scenario}: unknown provenance {artifact_id}")); + assert_eq!( + item["bytesCopied"], artifact["bytesCopied"], + "{scenario}/{artifact_id}: expected bytes mirror manifest" + ); + assert_eq!( + item["encoding"], artifact["encoding"], + "{scenario}/{artifact_id}: expected encoding mirrors manifest" + ); + assert_eq!( + item["byteLimit"], artifact["collectionLimit"]["byteLimit"], + "{scenario}/{artifact_id}: expected byte limit mirrors manifest" + ); + assert_eq!( + item["limitApplied"], artifact["collectionLimit"]["limitApplied"], + "{scenario}/{artifact_id}: expected cap flag mirrors manifest" + ); + } + + let transaction_ids = sorted_ids(&expected["transactions"], "transactionId"); + let mut sorted_transaction_ids = transaction_ids.clone(); + sorted_transaction_ids.sort(); + assert_eq!( + transaction_ids, sorted_transaction_ids, + "{scenario}: transaction order" + ); + let finding_ids = sorted_ids(&expected["findings"], "findingId"); + let mut sorted_finding_ids = finding_ids.clone(); + sorted_finding_ids.sort(); + assert_eq!(finding_ids, sorted_finding_ids, "{scenario}: finding order"); + + let mut evidence_refs = Vec::new(); + collect_evidence_refs(&expected, &mut evidence_refs); + for (artifact_id, start_line, end_line) in evidence_refs { + let artifact = artifacts_by_id + .get(artifact_id.as_str()) + .unwrap_or_else(|| panic!("{scenario}: unknown evidence artifact {artifact_id}")); + let relative_path = artifact["relativePath"] + .as_str() + .unwrap_or_else(|| panic!("{scenario}/{artifact_id}: evidence is not captured")); + let contents = std::fs::read_to_string(scenario_root.join(relative_path)) + .expect("evidence fixture is readable"); + let line_count = contents.lines().count() as u64; + assert!( + start_line >= 1 && end_line >= start_line && end_line <= line_count, + "{scenario}/{artifact_id}: invalid evidence lines {start_line}-{end_line}/{line_count}" + ); + } + + for transaction in expected["transactions"] + .as_array() + .expect("transactions are an array") + { + if let Some(next_artifact) = transaction["nextArtifact"].as_object() { + let logical_id = next_artifact["logicalArtifactId"] + .as_str() + .expect("next artifact logical ID is a string"); + assert!( + matches!( + logical_id, + "client-app-intent" + | "client-app-enforce" + | "client-content" + | "client-policy-state" + ), + "{scenario}: unbounded next artifact {logical_id}" + ); + assert_ne!( + logical_id, "client-policy-agent", + "{scenario}: deployment must not depend on policy output" + ); + } + } + + for file in walk_files(&scenario_root) { + let contents = std::fs::read_to_string(&file).expect("fixture file is UTF-8"); + for forbidden in [ + "CONTOSO", + ".log.lo_", + "C:\\Users\\", + "Authorization:", + "Bearer ", + "client_secret", + "S-1-", + ] { + assert!( + !contents.contains(forbidden), + "{} contains forbidden fixture material {forbidden}", + file.display() + ); + } + for (label, pattern) in &privacy_patterns { + assert!( + !pattern.is_match(&contents), + "{} contains possible {label}", + file.display() + ); + } + } + } +} + +#[test] +fn deployment_outcomes_keep_phases_and_coverage_conservative() { + let cases = [ + ( + "bits-transfer-failure", + 1, + "transfer", + "failed", + "locateContent", + "confirmedFailure", + "high", + None, + ), + ( + "cache-failure", + 1, + "cache", + "failed", + "transfer", + "confirmedFailure", + "high", + None, + ), + ( + "dependency-failure", + 1, + "requirements", + "failed", + "intent", + "confirmedFailure", + "high", + None, + ), + ( + "detection-false-negative", + 1, + "detect", + "detectionMismatch", + "enforce", + "symptom", + "medium", + None, + ), + ( + "dp-content-missing", + 1, + "locateContent", + "insufficientEvidence", + "requirements", + "insufficientEvidence", + "low", + Some("client-content"), + ), + ( + "enforcement-exit", + 1, + "enforce", + "failed", + "cache", + "confirmedFailure", + "high", + None, + ), + ( + "incomplete", + 2, + "locateContent", + "insufficientEvidence", + "requirements", + "insufficientEvidence", + "low", + Some("client-content"), + ), + ( + "location-missing", + 1, + "locateContent", + "insufficientEvidence", + "requirements", + "insufficientEvidence", + "low", + Some("client-content"), + ), + ( + "not-targeted", + 1, + "intent", + "notTargeted", + "", + "notTargeted", + "high", + None, + ), + ( + "requirements-failure", + 1, + "requirements", + "failed", + "intent", + "confirmedFailure", + "high", + None, + ), + ( + "rotation-boundary", + 1, + "locateContent", + "insufficientEvidence", + "requirements", + "insufficientEvidence", + "low", + Some("client-content"), + ), + ( + "success", + 1, + "report", + "succeeded", + "report", + "success", + "high", + None, + ), + ]; + + for ( + scenario, + transaction_count, + phase, + state, + last_success, + classification, + confidence, + next_artifact, + ) in cases + { + let expected = load_json(&deployment_root().join(scenario).join("expected.json")); + let transactions = expected["transactions"] + .as_array() + .expect("transactions are an array"); + assert_eq!( + transactions.len(), + transaction_count, + "{scenario}: transaction count" + ); + for transaction in transactions { + assert_eq!(transaction["phase"], phase, "{scenario}: phase"); + assert_eq!(transaction["state"], state, "{scenario}: state"); + if last_success.is_empty() { + assert!( + transaction["lastSuccessfulPhase"].is_null(), + "{scenario}: last successful phase" + ); + } else { + assert_eq!( + transaction["lastSuccessfulPhase"], last_success, + "{scenario}: last successful phase" + ); + } + assert_eq!( + transaction["classification"], classification, + "{scenario}: classification" + ); + assert_eq!( + transaction["confidence"], confidence, + "{scenario}: confidence" + ); + assert_eq!( + transaction["confidenceCeiling"], confidence, + "{scenario}: confidence ceiling" + ); + match next_artifact { + Some(logical_id) => assert_eq!( + transaction["nextArtifact"]["logicalArtifactId"], logical_id, + "{scenario}: next artifact" + ), + None => assert!( + transaction["nextArtifact"].is_null(), + "{scenario}: unexpected next artifact" + ), + } + } + } +} + +#[test] +fn counterpart_facts_require_exact_keys_and_adversarial_inputs_stay_unlinked() { + let counterpart_scenarios = [ + "bits-transfer-failure", + "cache-failure", + "detection-false-negative", + "dp-content-missing", + "enforcement-exit", + "success", + ]; + + for scenario in SCENARIOS { + let expected = load_json(&deployment_root().join(scenario).join("expected.json")); + let should_emit = counterpart_scenarios.contains(&scenario); + let mut emitted = 0; + + for transaction in expected["transactions"] + .as_array() + .expect("transactions are an array") + { + let fact = &transaction["counterpartReadyFact"]; + if fact.is_null() { + continue; + } + emitted += 1; + assert!(should_emit, "{scenario}: unexpected counterpart-ready fact"); + assert_eq!( + transaction["key"]["confidence"], "exact", + "{scenario}: counterpart transaction key confidence" + ); + assert_eq!( + transaction["key"]["extractionProfileId"], "deployment-client-5.00.test-v1", + "{scenario}: counterpart transaction profile" + ); + assert_eq!( + fact["extractionProfileId"], transaction["key"]["extractionProfileId"], + "{scenario}: counterpart profile" + ); + for field in [ + "packageId", + "contentId", + "contentVersion", + "distributionPointHostHandle", + "requestId", + ] { + assert_eq!( + fact[field], transaction["key"][field], + "{scenario}: counterpart {field}" + ); + } + assert_eq!( + fact["timestampProvenance"]["kind"], "explicitOffset", + "{scenario}: timestamp provenance" + ); + assert_eq!( + fact["timestampProvenance"]["offsetMinutes"], 0, + "{scenario}: timestamp offset" + ); + assert_eq!( + fact["phase"], "locateContent", + "{scenario}: counterpart phase" + ); + } + + assert_eq!( + emitted, + usize::from(should_emit), + "{scenario}: counterpart-ready fact count" + ); + assert_eq!( + expected["correlationHandoff"]["performed"], false, + "{scenario}: #333 is not performed" + ); + assert_eq!( + expected["correlationHandoff"]["timeOnlyEligible"], false, + "{scenario}: time-only cannot correlate" + ); + assert_eq!( + expected["correlationHandoff"]["topologyCompatibilityEvaluated"], false, + "{scenario}: topology belongs to #333" + ); + assert_eq!( + expected["correlationHandoff"]["serverCauseClaimed"], false, + "{scenario}: no DP/server cause" + ); + } + + let incomplete = load_json(&deployment_root().join("incomplete").join("expected.json")); + let transactions = incomplete["transactions"] + .as_array() + .expect("incomplete transactions are an array"); + assert_eq!(transactions.len(), 2); + assert_ne!( + transactions[0]["transactionId"], transactions[1]["transactionId"], + "same-minute exact keys must stay separate" + ); + assert_eq!( + incomplete["adversarialControls"]["sameMinuteDifferentExactKeysStaySeparate"], + true + ); + + let enforcement = load_json( + &deployment_root() + .join("enforcement-exit") + .join("expected.json"), + ); + let observations = enforcement["sourceLocalObservations"] + .as_array() + .expect("source-local observations are an array"); + assert_eq!(observations.len(), 1); + assert_eq!(observations[0]["keyConfidence"], "none"); + assert_eq!(observations[0]["confidenceCeiling"], "low"); + assert_eq!(observations[0]["correlationEligible"], false); + + let success = load_json(&deployment_root().join("success").join("expected.json")); + assert!( + success["coverage"] + .as_array() + .expect("success coverage is an array") + .iter() + .any(|coverage| { + coverage["logicalArtifactId"] == "client-policy-agent" + && coverage["state"] == "absent" + }), + "success must prove deployment independence from absent policy coverage" + ); + assert_eq!(success["transactions"][0]["state"], "succeeded"); +} 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. From 25b37333affde22b4ef8a19f4a5f3d89c082b599 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:32:38 -0400 Subject: [PATCH 019/422] feat(sccm): add versioned correlation keys (#350) Add fail-closed, versioned SCCM correlation-key extraction with conservative Low-confidence profiles and whole-token boundary validation. Refs #318. --- crates/cmtraceopen-parser/src/sccm/keys.rs | 452 +++++++++++++ crates/cmtraceopen-parser/src/sccm/mod.rs | 2 + crates/cmtraceopen-parser/src/sccm/models.rs | 88 +++ .../tests/sccm_spine_contract.rs | 633 +++++++++++++++++- 4 files changed, 1171 insertions(+), 4 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/keys.rs diff --git a/crates/cmtraceopen-parser/src/sccm/keys.rs b/crates/cmtraceopen-parser/src/sccm/keys.rs new file mode 100644 index 000000000..a019684a0 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/keys.rs @@ -0,0 +1,452 @@ +use std::sync::OnceLock; + +use regex::Regex; + +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"; +const EXPERIMENTAL_VERSION_PREFIX: &str = "5.00.9128."; + +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, + }, + } + } +} + +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:\b(?:assignment|policy)[ \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() + }) +} + +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 { + let candidates = find_candidates(&evidence.message); + let mut result = SccmKeyExtractionResult { + profile_id: profile.profile_id.clone(), + keys: Vec::new(), + gaps: Vec::new(), + }; + + if let Some(kind) = profile_gap_kind(profile) { + 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; + } + + result.gaps.push(gap_for( + SccmExtractionGapKind::ExperimentalProfile, + profile, + evidence, + None, + )); + + for candidate in candidates { + let mut key = normalize_key(candidate.kind.clone(), candidate.raw); + if key.confidence != SccmKeyConfidence::Exact { + result.gaps.push(gap_for( + SccmExtractionGapKind::MalformedCandidate, + profile, + evidence, + Some(&candidate), + )); + continue; + } + + key.confidence = 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 +} + +fn profile_gap_kind(profile: &SccmExtractionProfile) -> 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::Experimental | SccmExtractionProfileMaturity::Stable => { + Some(SccmExtractionGapKind::UnvalidatedProfile) + } + } +} + +fn is_builtin_experimental(profile: &SccmExtractionProfile) -> bool { + profile.profile_id == SCCM_EXPERIMENTAL_KEY_PROFILE_ID + && profile.configmgr_version_prefixes == [EXPERIMENTAL_VERSION_PREFIX] + && profile.validated_artifact_families.is_empty() + && 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(message: &str) -> Vec> { + let mut candidates = key_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::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), + }; + + normalized.map_or_else( + || (trimmed.to_ascii_lowercase(), SccmKeyConfidence::Low), + |normalized| (normalized, SccmKeyConfidence::Exact), + ) +} + +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 key_kind_order(kind: &SccmCorrelationKeyKind) -> u8 { + match kind { + SccmCorrelationKeyKind::AssignmentId => 0, + SccmCorrelationKeyKind::ClientGuid => 1, + SccmCorrelationKeyKind::PackageId => 2, + SccmCorrelationKeyKind::ContentId => 3, + SccmCorrelationKeyKind::SiteCode => 4, + SccmCorrelationKeyKind::ServerHost => 5, + SccmCorrelationKeyKind::CiId => 6, + SccmCorrelationKeyKind::UpdateId => 7, + SccmCorrelationKeyKind::KbId => 8, + SccmCorrelationKeyKind::BitsJobId => 9, + SccmCorrelationKeyKind::TaskSequenceExecutionId => 10, + SccmCorrelationKeyKind::RequestId => 11, + SccmCorrelationKeyKind::TopicId => 12, + SccmCorrelationKeyKind::StateMessageId => 13, + } +} diff --git a/crates/cmtraceopen-parser/src/sccm/mod.rs b/crates/cmtraceopen-parser/src/sccm/mod.rs index bc79d4840..d4c45f4ea 100644 --- a/crates/cmtraceopen-parser/src/sccm/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/mod.rs @@ -1,11 +1,13 @@ pub mod catalog; mod evidence; mod ingest; +mod keys; pub mod models; mod rotation; mod signals; pub use catalog::*; 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 index afcc4dae1..7ac19c6a8 100644 --- a/crates/cmtraceopen-parser/src/sccm/models.rs +++ b/crates/cmtraceopen-parser/src/sccm/models.rs @@ -2,6 +2,7 @@ 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; @@ -144,6 +145,93 @@ pub struct SccmEvidence { pub execution_context: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmCorrelationKeyKind { + AssignmentId, + ClientGuid, + PackageId, + ContentId, + SiteCode, + ServerHost, + CiId, + UpdateId, + KbId, + BitsJobId, + TaskSequenceExecutionId, + RequestId, + TopicId, + StateMessageId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmKeyConfidence { + Low, + Strong, + Exact, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +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, diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index f39ee8971..db08918c4 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -1,10 +1,12 @@ use cmtraceopen_parser::models::log_entry::{LogFormat, ParserKind}; use cmtraceopen_parser::parser::detect::detect_parser; use cmtraceopen_parser::sccm::{ - classify_artifact_name, declared_source_catalog, extract_signals, normalize_ccm_artifact, - SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmFindingClass, SccmRole, SccmRotation, - SccmSignal, SccmSignalKind, SccmTimeOrderingState, SccmUnknownRotation, - SCCM_DIAGNOSTICS_SCHEMA_VERSION, + classify_artifact_name, declared_source_catalog, extract_keys, extract_signals, + normalize_ccm_artifact, normalize_key, SccmArtifact, SccmArtifactFamily, + SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, SccmEvidenceRef, + SccmExtractionGapKind, SccmExtractionProfile, SccmExtractionProfileMaturity, SccmFindingClass, + SccmKeyConfidence, SccmKeyExtractionResult, SccmRole, SccmRotation, SccmSignal, SccmSignalKind, + SccmTimeOrderingState, SccmTimestamp, SccmUnknownRotation, SCCM_DIAGNOSTICS_SCHEMA_VERSION, }; fn client_policy_artifact() -> SccmArtifact { @@ -22,6 +24,29 @@ fn client_policy_artifact() -> SccmArtifact { } } +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 json_value_contains_sensitive(value: &serde_json::Value, sensitive: &str) -> bool { match value { serde_json::Value::String(value) => value.contains(sensitive), @@ -248,6 +273,606 @@ fn signal_extractor_is_deterministic_and_serializes_camel_case() { 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 public_ccm_malformed_continuation_stays_plain() { let text = " Date: Thu, 30 Jul 2026 18:47:04 -0400 Subject: [PATCH 020/422] test(sccm): prepare software update corpus --- .../fixtures/sccm/client/updates/README.md | 65 + .../client-updates/current/ScanAgent.log | 1 + .../updates/access-denied/expected.json | 147 ++ .../updates/access-denied/manifest.json | 66 + .../current/DataTransferService.log | 1 + .../current/LocationServices.log | 1 + .../client-updates/current/ScanAgent.log | 2 + .../sccm/client/updates/capped/expected.json | 195 +++ .../sccm/client/updates/capped/manifest.json | 100 ++ .../current/ContentTransferManager.log | 1 + .../current/LocationServices.log | 1 + .../client-updates/current/ScanAgent.log | 2 + .../updates/content-failure/expected.json | 196 +++ .../updates/content-failure/manifest.json | 99 ++ .../client-updates/current/ScanAgent.log | 1 + .../client-updates/current/WUAHandler.log | 1 + .../updates/evaluation-failure/expected.json | 149 ++ .../updates/evaluation-failure/manifest.json | 71 + .../current/UpdatesDeployment.log | 4 + .../client/updates/incomplete/expected.json | 173 +++ .../client/updates/incomplete/manifest.json | 112 ++ .../current/DataTransferService.log | 1 + .../current/LocationServices.log | 1 + .../client-updates/current/ScanAgent.log | 2 + .../client-updates/current/UpdatesHandler.log | 2 + .../updates/install-failure/expected.json | 213 +++ .../updates/install-failure/manifest.json | 127 ++ .../client-updates/current/ScanAgent.log | 1 + .../client-updates/current/UpdatesStore.log | 1 + .../updates/invalid-offset/expected.json | 162 +++ .../updates/invalid-offset/manifest.json | 71 + .../current/DataTransferService.log | 1 + .../current/LocationServices.log | 1 + .../current/ServiceWindowManager.log | 1 + .../client-updates/current/ScanAgent.log | 2 + .../updates/maintenance-window/expected.json | 226 +++ .../updates/maintenance-window/manifest.json | 127 ++ .../client-updates/current/ScanAgent.log | 1 + .../client/updates/malformed/expected.json | 147 ++ .../client/updates/malformed/manifest.json | 89 ++ .../client-updates/current/ScanAgent.log | 2 + .../sccm/client/updates/no-sup/expected.json | 151 ++ .../sccm/client/updates/no-sup/manifest.json | 66 + .../current/LocationServices.log | 1 + .../current/RebootCoordinator.log | 1 + .../client-updates/current/ScanAgent.log | 2 + .../current/UpdatesDeployment.log | 3 + .../updates/reboot-pending/expected.json | 221 +++ .../updates/reboot-pending/manifest.json | 127 ++ .../current/LocationServices.log | 1 + .../current/StateMessage.log | 1 + .../client-updates/current/ScanAgent.log | 2 + .../current/UpdatesDeployment.log | 4 + .../updates/reporting-failure/expected.json | 213 +++ .../updates/reporting-failure/manifest.json | 127 ++ .../client-updates/current/ScanAgent.log | 1 + .../evidence/client-updates/lo/ScanAgent.lo_ | 1 + .../updates/rotation-boundary/expected.json | 146 ++ .../updates/rotation-boundary/manifest.json | 73 + .../client-updates/current/UpdatesHandler.log | 2 + .../same-minute-separate/expected.json | 161 +++ .../same-minute-separate/manifest.json | 43 + .../client-updates/current/ScanAgent.log | 1 + .../client/updates/scan-failure/expected.json | 132 ++ .../client/updates/scan-failure/manifest.json | 43 + .../current/DataTransferService.log | 1 + .../current/LocationServices.log | 1 + .../current/ServiceWindowManager.log | 1 + .../current/StateMessage.log | 1 + .../current/RebootCoordinator.log | 1 + .../client-updates/current/ScanAgent.log | 2 + .../client-updates/current/UpdatesHandler.log | 1 + .../sccm/client/updates/success/expected.json | 242 ++++ .../sccm/client/updates/success/manifest.json | 234 +++ .../client-updates/current/UpdatesHandler.log | 1 + .../current/CBS.log | 1 + .../supplemental-conflict/expected.json | 171 +++ .../supplemental-conflict/manifest.json | 71 + .../sccm_client_updates_fixture_contract.rs | 1268 +++++++++++++++++ .../issue-323-client-updates-corpus.md | 205 +++ 80 files changed, 6290 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/access-denied/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/access-denied/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/access-denied/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/evidence/client-content/current/DataTransferService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/evidence/client-location-services-shared/current/LocationServices.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/evidence/client-content/current/ContentTransferManager.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/evidence/client-location-services-shared/current/LocationServices.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/evidence/client-updates/current/WUAHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/evaluation-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/evidence/client-updates/current/UpdatesDeployment.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-content/current/DataTransferService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-location-services-shared/current/LocationServices.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/evidence/client-updates/current/UpdatesHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/evidence/client-updates/current/UpdatesStore.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/invalid-offset/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-content/current/DataTransferService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-location-services-shared/current/LocationServices.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-maintenance-window/current/ServiceWindowManager.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/no-sup/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/no-sup/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/no-sup/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-location-services-shared/current/LocationServices.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-reboot/current/RebootCoordinator.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/evidence/client-updates/current/UpdatesDeployment.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-location-services-shared/current/LocationServices.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-policy-state/current/StateMessage.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/evidence/client-updates/current/UpdatesDeployment.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/rotation-boundary/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/rotation-boundary/evidence/client-updates/lo/ScanAgent.lo_ create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/rotation-boundary/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/rotation-boundary/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/same-minute-separate/evidence/client-updates/current/UpdatesHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/same-minute-separate/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/same-minute-separate/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/scan-failure/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/scan-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/scan-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-content/current/DataTransferService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-location-services-shared/current/LocationServices.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-maintenance-window/current/ServiceWindowManager.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-policy-state/current/StateMessage.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-reboot/current/RebootCoordinator.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-updates/current/ScanAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/evidence/client-updates/current/UpdatesHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/evidence/client-updates/current/UpdatesHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/evidence/client-windows-update-supplemental/current/CBS.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs create mode 100644 docs/sccm/preparation/issue-323-client-updates-corpus.md 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..08d5ba7f2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md @@ -0,0 +1,65 @@ +# 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. + +## 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. They remain client facts for future #330/#333 +work. Time alone is never eligible, topology is not evaluated here, and no +server cause is claimed. + +## 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, 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..34de18c6c --- /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": "safe:sup:lab-sup-01", + "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..5dc1771a7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/expected.json @@ -0,0 +1,195 @@ +{ + "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" + } + ], + "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": true, + "timeOnlyEligible": false, + "phase": "locateSup", + "extractionProfileId": "updates-client-5.00.test-v1", + "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..c293cefcf --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/content-failure/expected.json @@ -0,0 +1,196 @@ +{ + "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": true, + "timeOnlyEligible": false, + "phase": "locateSup", + "extractionProfileId": "updates-client-5.00.test-v1", + "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..0c0cef38e --- /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": "safe:sup:lab-sup-01", + "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..52c6b709a --- /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": "safe:sup:lab-sup-01", + "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..dfdba138b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/manifest.json @@ -0,0 +1,112 @@ +{ + "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", + "fragmentComplete": false + }, + "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", + "fragmentComplete": false + }, + "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", + "fragmentComplete": false + }, + "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..6f3fd1b34 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/install-failure/expected.json @@ -0,0 +1,213 @@ +{ + "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": true, + "timeOnlyEligible": false, + "phase": "locateSup", + "extractionProfileId": "updates-client-5.00.test-v1", + "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..3b4213689 --- /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": "safe:sup:lab-sup-01", + "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..09b76ed87 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/maintenance-window/expected.json @@ -0,0 +1,226 @@ +{ + "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": true, + "timeOnlyEligible": false, + "phase": "locateSup", + "extractionProfileId": "updates-client-5.00.test-v1", + "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..84f0d8d1b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/expected.json @@ -0,0 +1,147 @@ +{ + "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": [ + "client-updates" + ] + }, + "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..0d9a778c5 --- /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": "ccmLog", + "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..a18464cdb --- /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": "safe:sup:lab-sup-01", + "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..ad458d244 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/no-sup/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-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", + "fragmentComplete": false + }, + "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..60e94bc67 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reboot-pending/expected.json @@ -0,0 +1,221 @@ +{ + "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": true, + "timeOnlyEligible": false, + "phase": "locateSup", + "extractionProfileId": "updates-client-5.00.test-v1", + "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..279f62e61 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/reporting-failure/expected.json @@ -0,0 +1,213 @@ +{ + "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": true, + "timeOnlyEligible": false, + "phase": "locateSup", + "extractionProfileId": "updates-client-5.00.test-v1", + "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..e10548fe0 --- /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": "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-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": "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-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": "maintenanceWindow", + "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..71f81b64f --- /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": "safe:sup:lab-sup-01", + "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..07182f3c7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/expected.json @@ -0,0 +1,242 @@ +{ + "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": true, + "timeOnlyEligible": false, + "phase": "locateSup", + "extractionProfileId": "updates-client-5.00.test-v1", + "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..b8788de21 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/manifest.json @@ -0,0 +1,234 @@ +{ + "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": "ccmLog", + "captureState": "skipped", + "originalBasename": "ReportingEvents.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/WindowsUpdate/ReportingEvents.log", + "pathFingerprint": "synthetic:updates-success-08-supplemental-skipped", + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "sourceVersion": "5.00.TEST.0000", + "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..3cdf6082c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/supplemental-conflict/expected.json @@ -0,0 +1,171 @@ +{ + "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", + "client-windows-update-supplemental" + ] + }, + "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": "safe:sup:lab-sup-01", + "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": "install", + "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": "install", + "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..e11700970 --- /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": "5.00.TEST.0000", + "capturedUtc": "2026-07-30T18:59:59Z", + "bytesCopied": 117, + "relativePath": "evidence/client-windows-update-supplemental/current/CBS.log" + } + ] +} 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..18063f198 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -0,0 +1,1268 @@ +use cmtraceopen_parser::{ + models::log_entry::LogFormat, + parser::{parse_content_with_selection, ResolvedParser}, +}; +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_CORPUS_FNV1A64: u64 = 0x1ff6_72e5_1adb_eb52; +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")], + 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: Some("install"), + 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) -> String { + value[field] + .as_str() + .unwrap_or_else(|| panic!("{field} must be a string")) + .to_owned() +} + +fn optional_json_string(value: &Value, field: &str) -> Option { + value[field].as_str().map(str::to_owned) +} + +fn sorted_strings(values: &Value, field: &str) -> Vec { + values[field] + .as_array() + .unwrap_or_else(|| panic!("{field} must be an array")) + .iter() + .map(|value| { + value + .as_str() + .unwrap_or_else(|| panic!("{field} values must be strings")) + .to_owned() + }) + .collect() +} + +fn subject<'a>(expected: &'a Value, contract: &ScenarioContract) -> &'a Value { + if contract.observations > 0 { + &expected["sourceLocalObservations"][0] + } else { + &expected["transactions"][0] + } +} + +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 = sorted_strings(expected, "stateChain"); + 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 transactions = expected["transactions"] + .as_array() + .expect("transactions must be an array"); + let observations = expected["sourceLocalObservations"] + .as_array() + .expect("sourceLocalObservations must be an array"); + let findings = expected["findings"] + .as_array() + .expect("findings must be an array"); + 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 transaction_ids = transactions + .iter() + .map(|transaction| json_string(transaction, "transactionId")) + .collect::>(); + 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" + )); + } + } + let subject_ids = transactions + .iter() + .map(|transaction| json_string(transaction, "transactionId")) + .chain( + observations + .iter() + .map(|observation| json_string(observation, "observationId")), + ) + .collect::>(); + for finding in findings { + let subject_id = json_string(finding, "subjectId"); + 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 { + let subject = subject(expected, contract); + if optional_json_string(subject, "phase").as_deref() != contract.phase + || json_string(subject, "state") != contract.state + || json_string(subject, "classification") != contract.classification + || json_string(subject, "confidenceCeiling") != 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 + )); + } + } + + let coverage = expected["coverage"] + .as_array() + .expect("coverage must be an array"); + let coverage_ids = coverage + .iter() + .map(|entry| json_string(entry, "logicalArtifactId")) + .collect::>(); + let mut sorted_coverage_ids = coverage_ids.clone(); + sorted_coverage_ids.sort(); + if coverage_ids != sorted_coverage_ids { + failures.push(format!("{scenario}: coverage must be sorted")); + } + for (logical_id, state) in contract.coverage { + if !coverage + .iter() + .any(|entry| entry["logicalArtifactId"] == *logical_id && entry["state"] == *state) + { + failures.push(format!("{scenario}: missing coverage {logical_id}={state}")); + } + } + + let handoff = &expected["correlationHandoff"]; + let facts = handoff["counterpartReadyFacts"] + .as_array() + .expect("counterpartReadyFacts must be an array"); + 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"] != true + || 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" + )); + } + } + + let prohibited = sorted_strings(expected, "prohibitedClaims").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:?}" + )); + } + } + + 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" + && (transactions[0]["ordering"]["crossArtifactComparable"] != false + || transactions[0]["ordering"]["highConfidenceEligible"] != false + || transactions[0]["ordering"]["reason"] != "invalidOffset") + { + failures.push( + "invalid-offset: invalid provenance must disable cross-artifact high confidence" + .to_owned(), + ); + } + if scenario == "same-minute-separate" { + let update_ids = transactions + .iter() + .map(|transaction| json_string(&transaction["key"], "updateId")) + .collect::>(); + if update_ids.len() != 2 { + failures.push( + "same-minute-separate: exact update keys must remain two transactions".to_owned(), + ); + } + } + if scenario == "supplemental-conflict" + && (transactions[0]["state"] != "succeeded" + || observations[0]["keyConfidence"] != "none" + || observations[0]["confidenceCeiling"] != "low") + { + failures.push( + "supplemental-conflict: unkeyed CBS evidence cannot override client success".to_owned(), + ); + } + + failures +} + +fn counterpart_source_failures( + scenario_dir: &Path, + manifest: &Value, + expected: &Value, +) -> Vec { + let mut failures = Vec::new(); + let Some(artifacts) = manifest["artifacts"].as_array() else { + return vec!["manifest artifacts must be an array".to_owned()]; + }; + let Some(facts) = expected["correlationHandoff"]["counterpartReadyFacts"].as_array() else { + return vec!["counterpartReadyFacts must be an array".to_owned()]; + }; + + for fact in facts { + 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) = artifacts + .iter() + .find(|artifact| artifact["artifactId"] == artifact_id) + else { + failures.push(format!( + "{artifact_id}: counterpart fact needs explicit LocationServices LocateSup evidence" + )); + continue; + }; + let is_complete_location_source = artifact["designOnlyCatalog"]["entryId"] + == "client-location-services-shared" + && artifact["kind"] == "ccmLog" + && artifact["captureState"] == "captured" + && artifact["rotation"]["fragmentComplete"] == true + && artifact["originalBasename"] == "LocationServices.log"; + let start_line = fact["evidence"]["startLine"].as_u64(); + let end_line = fact["evidence"]["endLine"].as_u64(); + let Some(relative_path) = artifact["relativePath"].as_str() else { + failures.push(format!( + "{artifact_id}: counterpart fact needs explicit LocationServices LocateSup evidence" + )); + continue; + }; + let cited_line = start_line + .filter(|line| Some(*line) == end_line && *line > 0) + .and_then(|line| { + std::fs::read_to_string(scenario_dir.join(relative_path)) + .ok()? + .lines() + .nth((line - 1) as usize) + .map(str::to_owned) + }); + let direct_markers = [ + format!( + "UpdateId={{{}}}", + fact["updateId"].as_str().unwrap_or_default() + ), + format!("CiId={}", fact["ciId"].as_str().unwrap_or_default()), + format!( + "ContentId={}", + fact["contentId"].as_str().unwrap_or_default() + ), + format!( + "UpdateJobId={}", + fact["updateJobId"].as_str().unwrap_or_default() + ), + format!( + "ClientHandle={}", + fact["clientHandle"].as_str().unwrap_or_default() + ), + format!("SiteCode={}", fact["siteCode"].as_str().unwrap_or_default()), + format!( + "SupHostHandle={}", + fact["supHostHandle"].as_str().unwrap_or_default() + ), + ]; + if !is_complete_location_source + || cited_line.as_deref().is_none_or(|line| { + !line.contains("LocateSup selected") + || direct_markers.iter().any(|marker| !line.contains(marker)) + }) + { + failures.push(format!( + "{artifact_id}: counterpart fact needs explicit LocationServices LocateSup evidence" + )); + } + } + + 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 = artifact["rotation"]["fragmentComplete"] + .as_bool() + .unwrap_or(false); + let physical = matches!(state, "captured" | "capped"); + + if matches!( + state, + "absent" | "accessDenied" | "capped" | "skipped" | "unsupported" | "parseFailed" + ) && fragment_complete + { + 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 !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(_) + ) + }) + { + 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 = artifact["collectionLimit"]["byteLimit"] + .as_u64() + .unwrap_or_default(); + 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" + )); + } + } else { + failures.push(format!("{artifact_id}: unknown captureState {state}")); + } + + failures +} + +fn artifact_matches_coverage(artifact: &Value, logical_id: &str, state: &str) -> bool { + let memberships = artifact["designOnlyCatalog"]["groupMemberships"] + .as_array() + .expect("groupMemberships must be an array"); + if !memberships.iter().any(|value| value == logical_id) { + return false; + } + if state == "partial" { + artifact["captureState"] == "captured" && artifact["rotation"]["fragmentComplete"] == false + } else { + artifact["captureState"] == state + } +} + +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 +} + +#[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")); + + 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!( + "{}: manifest identity/proposal boundary drifted", + contract.name + )); + } + + let artifacts = manifest["artifacts"] + .as_array() + .expect("manifest artifacts must be an array"); + let artifact_ids = artifacts + .iter() + .map(|artifact| json_string(artifact, "artifactId")) + .collect::>(); + let mut sorted_artifact_ids = artifact_ids.clone(); + sorted_artifact_ids.sort(); + if artifact_ids != sorted_artifact_ids { + failures.push(format!( + "{}: manifest artifacts must be sorted by artifactId", + contract.name + )); + } + if artifact_ids.iter().collect::>().len() != artifact_ids.len() { + failures.push(format!("{}: artifact IDs must be unique", contract.name)); + } + + for (logical_id, state) in contract.coverage { + if !artifacts + .iter() + .any(|artifact| artifact_matches_coverage(artifact, logical_id, state)) + { + failures.push(format!( + "{}: manifest does not support expected coverage {logical_id}={state}", + contract.name + )); + } + } + failures.extend(expected_boundary_failures(&expected, contract)); + failures.extend(counterpart_source_failures( + &scenario_dir, + &manifest, + &expected, + )); + } + + 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 line_counts = BTreeMap::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")); + 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 = json_string(artifact, "artifactId"); + 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()) + }); + 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; + line_counts.insert(artifact_id.clone(), lines); + + 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 + )); + } + } 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" + ); + + corpus_items.sort_by(|left, right| left.0.cmp(&right.0)); + 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 drifted" + ); + + 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); + + 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_" + ); + + 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_ne!(wrong_site["bundle"]["siteCode"], "LAB"); + + 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")) + ); +} 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..6cc276b11 --- /dev/null +++ b/docs/sccm/preparation/issue-323-client-updates-corpus.md @@ -0,0 +1,205 @@ +# 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. + +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. + +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. + +## Supplemental servicing boundary + +CBS, DISM, Windows Update, and ReportingEvents evidence remains separately +typed with explicit provenance. 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. | Per transaction | 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; +- 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. + +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. From 442ef07e54ea71cccf10910cff9a8c276c6e2d97 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 19:29:30 -0400 Subject: [PATCH 021/422] fix(sccm): bind software update fixture evidence --- .../fixtures/sccm/client/updates/README.md | 17 +- .../updates/access-denied/expected.json | 2 +- .../sccm/client/updates/capped/expected.json | 14 + .../updates/content-failure/expected.json | 6 + .../updates/evaluation-failure/expected.json | 2 +- .../client/updates/incomplete/expected.json | 2 +- .../client/updates/incomplete/manifest.json | 9 +- .../updates/install-failure/expected.json | 6 + .../updates/invalid-offset/expected.json | 2 +- .../updates/maintenance-window/expected.json | 6 + .../client/updates/malformed/expected.json | 4 +- .../client/updates/malformed/manifest.json | 2 +- .../sccm/client/updates/no-sup/expected.json | 2 +- .../sccm/client/updates/no-sup/manifest.json | 3 +- .../updates/reboot-pending/expected.json | 6 + .../updates/reporting-failure/expected.json | 6 + .../updates/rotation-boundary/expected.json | 4 +- .../same-minute-separate/expected.json | 4 +- .../client/updates/scan-failure/expected.json | 2 +- .../sccm/client/updates/success/expected.json | 6 + .../sccm/client/updates/success/manifest.json | 3 +- .../supplemental-conflict/expected.json | 5 +- .../sccm_client_updates_fixture_contract.rs | 1426 +++++++++++++++-- .../issue-323-client-updates-corpus.md | 10 +- 24 files changed, 1360 insertions(+), 189 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md index 08d5ba7f2..2e7153a51 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md @@ -43,9 +43,15 @@ 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. They remain client facts for future #330/#333 -work. Time alone is never eligible, topology is not evaluated here, and no -server cause is claimed. +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. Time alone is never eligible, +topology is not evaluated here, and no server cause is claimed. + +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. ## Replay @@ -61,5 +67,6 @@ 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, stable outcome labels, -independent-reducer boundary, and correlation-safe facts. +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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/access-denied/expected.json index 34de18c6c..f7aa51875 100644 --- 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 @@ -68,7 +68,7 @@ "updateJobId": "JOB-UPDATE-13", "clientHandle": "safe:client:updates-13", "siteCode": "LAB", - "supHostHandle": "safe:sup:lab-sup-01", + "supHostHandle": null, "confidence": "exact", "extractionProfileId": "updates-client-5.00.test-v1" }, 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 index 5dc1771a7..23c33bca5 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/expected.json @@ -41,6 +41,14 @@ { "logicalArtifactId": "client-content", "state": "capped" + }, + { + "logicalArtifactId": "client-location-services-shared", + "state": "captured" + }, + { + "logicalArtifactId": "client-updates", + "state": "captured" } ], "artifactProvenance": [ @@ -178,6 +186,12 @@ "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, 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 index c293cefcf..baccf8ceb 100644 --- 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 @@ -179,6 +179,12 @@ "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, 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 index 0c0cef38e..faca40305 100644 --- 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 @@ -68,7 +68,7 @@ "updateJobId": "JOB-UPDATE-04", "clientHandle": "safe:client:updates-04", "siteCode": "LAB", - "supHostHandle": "safe:sup:lab-sup-01", + "supHostHandle": null, "confidence": "exact", "extractionProfileId": "updates-client-5.00.test-v1" }, 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 index 52c6b709a..bb41c279d 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/expected.json @@ -94,7 +94,7 @@ "updateJobId": "JOB-UPDATE-10", "clientHandle": "safe:client:updates-10", "siteCode": "LAB", - "supHostHandle": "safe:sup:lab-sup-01", + "supHostHandle": null, "confidence": "exact", "extractionProfileId": "updates-client-5.00.test-v1" }, 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 index dfdba138b..1dda221da 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/incomplete/manifest.json @@ -54,8 +54,7 @@ "sanitizedSourcePath": null, "pathFingerprint": null, "rotation": { - "kind": "current", - "fragmentComplete": false + "kind": "current" }, "sourceVersion": null, "capturedUtc": "2026-07-30T11:59:59Z", @@ -77,8 +76,7 @@ "sanitizedSourcePath": null, "pathFingerprint": null, "rotation": { - "kind": "current", - "fragmentComplete": false + "kind": "current" }, "sourceVersion": null, "capturedUtc": "2026-07-30T11:59:59Z", @@ -100,8 +98,7 @@ "sanitizedSourcePath": null, "pathFingerprint": null, "rotation": { - "kind": "current", - "fragmentComplete": false + "kind": "current" }, "sourceVersion": null, "capturedUtc": "2026-07-30T11:59:59Z", 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 index 6f3fd1b34..0705037ad 100644 --- 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 @@ -196,6 +196,12 @@ "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, 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 index 3b4213689..8465974a5 100644 --- 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 @@ -68,7 +68,7 @@ "updateJobId": "JOB-UPDATE-14", "clientHandle": "safe:client:updates-14", "siteCode": "LAB", - "supHostHandle": "safe:sup:lab-sup-01", + "supHostHandle": null, "confidence": "exact", "extractionProfileId": "updates-client-5.00.test-v1" }, 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 index 09b76ed87..fa9e70a51 100644 --- 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 @@ -209,6 +209,12 @@ "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, 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 index 84f0d8d1b..f5ee6b695 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/expected.json @@ -31,9 +31,7 @@ "siteCode", "supHostHandle" ], - "validatedArtifactFamilies": [ - "client-updates" - ] + "validatedArtifactFamilies": [] }, "reorderedInputDeterministic": true, "coverage": [ 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 index 0d9a778c5..e37152270 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/malformed/manifest.json @@ -71,7 +71,7 @@ ] }, "role": "client", - "kind": "ccmLog", + "kind": "cbsLog", "captureState": "unsupported", "originalBasename": "CBS.log", "sanitizedSourcePath": "SYNTHETIC://root-a/Windows/Logs/CBS/CBS.log", 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 index a18464cdb..0069bf224 100644 --- 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 @@ -72,7 +72,7 @@ "updateJobId": "JOB-UPDATE-02", "clientHandle": "safe:client:updates-02", "siteCode": "LAB", - "supHostHandle": "safe:sup:lab-sup-01", + "supHostHandle": null, "confidence": "exact", "extractionProfileId": "updates-client-5.00.test-v1" }, 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 index ad458d244..47a36cadc 100644 --- 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 @@ -54,8 +54,7 @@ "sanitizedSourcePath": null, "pathFingerprint": null, "rotation": { - "kind": "current", - "fragmentComplete": false + "kind": "current" }, "sourceVersion": null, "capturedUtc": "2026-07-30T03:59:59Z", 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 index 60e94bc67..ac9488251 100644 --- 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 @@ -204,6 +204,12 @@ "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, 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 index 279f62e61..69818c7a3 100644 --- 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 @@ -196,6 +196,12 @@ "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, diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/rotation-boundary/expected.json index 5fe320b4a..79081040d 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/rotation-boundary/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/rotation-boundary/expected.json @@ -31,9 +31,7 @@ "siteCode", "supHostHandle" ], - "validatedArtifactFamilies": [ - "client-updates" - ] + "validatedArtifactFamilies": [] }, "reorderedInputDeterministic": true, "coverage": [ 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 index e10548fe0..bf31fcaa1 100644 --- 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 @@ -61,7 +61,7 @@ "updateJobId": "JOB-UPDATE-15", "clientHandle": "safe:client:updates-15", "siteCode": "LAB", - "supHostHandle": "safe:sup:lab-sup-01", + "supHostHandle": null, "confidence": "exact", "extractionProfileId": "updates-client-5.00.test-v1" }, @@ -90,7 +90,7 @@ "updateJobId": "JOB-UPDATE-16", "clientHandle": "safe:client:updates-16", "siteCode": "LAB", - "supHostHandle": "safe:sup:lab-sup-01", + "supHostHandle": null, "confidence": "exact", "extractionProfileId": "updates-client-5.00.test-v1" }, 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 index 71f81b64f..e0c2645b5 100644 --- 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 @@ -61,7 +61,7 @@ "updateJobId": "JOB-UPDATE-03", "clientHandle": "safe:client:updates-03", "siteCode": "LAB", - "supHostHandle": "safe:sup:lab-sup-01", + "supHostHandle": null, "confidence": "exact", "extractionProfileId": "updates-client-5.00.test-v1" }, 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 index 07182f3c7..5c1e020c5 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/expected.json @@ -225,6 +225,12 @@ "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, 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 index b8788de21..0a6490911 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/manifest.json @@ -222,8 +222,7 @@ "sanitizedSourcePath": "SYNTHETIC://root-a/WindowsUpdate/ReportingEvents.log", "pathFingerprint": "synthetic:updates-success-08-supplemental-skipped", "rotation": { - "kind": "current", - "fragmentComplete": false + "kind": "current" }, "sourceVersion": "5.00.TEST.0000", "capturedUtc": "2026-07-30T02:59:59Z", 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 index 3cdf6082c..d9e8a8e86 100644 --- 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 @@ -32,8 +32,7 @@ "supHostHandle" ], "validatedArtifactFamilies": [ - "client-updates", - "client-windows-update-supplemental" + "client-updates" ] }, "reorderedInputDeterministic": true, @@ -73,7 +72,7 @@ "updateJobId": "JOB-UPDATE-17", "clientHandle": "safe:client:updates-17", "siteCode": "LAB", - "supHostHandle": "safe:sup:lab-sup-01", + "supHostHandle": null, "confidence": "exact", "extractionProfileId": "updates-client-5.00.test-v1" }, diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 18063f198..af5217514 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -1,6 +1,11 @@ +use chrono::{DateTime, SecondsFormat, Utc}; use cmtraceopen_parser::{ models::log_entry::LogFormat, parser::{parse_content_with_selection, ResolvedParser}, + sccm::{ + normalize_ccm_artifact, SccmArtifact, SccmCoverageState, SccmEvidence, SccmRole, + SccmRotation, SccmTimeOrderingState, + }, }; use serde_json::Value; use std::{ @@ -20,8 +25,25 @@ const STATE_CHAIN: [&str; 8] = [ ]; 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" Option { value[field].as_str().map(str::to_owned) } -fn sorted_strings(values: &Value, field: &str) -> Vec { +fn string_array(values: &Value, field: &str) -> Vec { values[field] .as_array() .unwrap_or_else(|| panic!("{field} must be an array")) @@ -383,6 +409,23 @@ fn subject<'a>(expected: &'a Value, contract: &ScenarioContract) -> &'a Value { } } +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; @@ -395,7 +438,7 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> if expected["workflow"] != "updates" || expected["scenario"] != scenario { failures.push(format!("{scenario}: workflow/scenario identity drifted")); } - let state_chain = sorted_strings(expected, "stateChain"); + let state_chain = string_array(expected, "stateChain"); if state_chain != STATE_CHAIN { failures.push(format!("{scenario}: state chain drifted: {state_chain:?}")); } @@ -517,22 +560,24 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> let coverage = expected["coverage"] .as_array() .expect("coverage must be an array"); - let coverage_ids = coverage + let coverage_pairs = coverage .iter() - .map(|entry| json_string(entry, "logicalArtifactId")) + .map(|entry| { + ( + json_string(entry, "logicalArtifactId"), + json_string(entry, "state"), + ) + }) .collect::>(); - let mut sorted_coverage_ids = coverage_ids.clone(); - sorted_coverage_ids.sort(); - if coverage_ids != sorted_coverage_ids { - failures.push(format!("{scenario}: coverage must be sorted")); - } - for (logical_id, state) in contract.coverage { - if !coverage - .iter() - .any(|entry| entry["logicalArtifactId"] == *logical_id && entry["state"] == *state) - { - failures.push(format!("{scenario}: missing coverage {logical_id}={state}")); - } + 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"]; @@ -578,7 +623,7 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> } } - let prohibited = sorted_strings(expected, "prohibitedClaims").join("\n"); + let prohibited = string_array(expected, "prohibitedClaims").join("\n"); for required in [ "SUP or server root cause", "time-only cross-artifact causality", @@ -645,90 +690,847 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> failures } -fn counterpart_source_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 citation_failures( + label: &str, + citations: &Value, + index: &BTreeMap, +) -> Vec { + let mut failures = 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" + )); + } + } + 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 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 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 actual_gaps = string_array(transaction, "coverageGapArtifactIds"); + if actual_gaps != expected_transaction_gaps(scenario) { + failures.push(format!( + "{scenario}: coverage gaps drifted for {transaction_id}: {actual_gaps:?}" + )); + } + } + 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 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 = if basename == Some("CBS.log") { + "cbsLog" + } else { + "ccmLog" + }; + if group.is_none() + || (group != Some("client-windows-update-supplemental") && artifact["kind"] != "ccmLog") + || artifact["kind"] != expected_kind + { + vec![format!( + "{artifact_id}: artifact kind {:?} is incompatible with group/basename", + artifact["kind"] + )] + } else { + Vec::new() + } +} + +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 vec!["manifest artifacts must be an array".to_owned()]; + 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 validated_families = string_array(profile, "validatedArtifactFamilies"); + 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" + )); + } + for artifact in artifacts { + failures.extend( + manifest_artifact_failures(scenario_dir, 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 +} + +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 fact["keyConfidence"] != "exact" + || fact["correlationEligible"] != true + || 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) = artifacts - .iter() - .find(|artifact| artifact["artifactId"] == artifact_id) - else { + 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["designOnlyCatalog"]["entryId"] + let is_complete_location_source = artifact.manifest["designOnlyCatalog"]["entryId"] == "client-location-services-shared" - && artifact["kind"] == "ccmLog" - && artifact["captureState"] == "captured" - && artifact["rotation"]["fragmentComplete"] == true - && artifact["originalBasename"] == "LocationServices.log"; - let start_line = fact["evidence"]["startLine"].as_u64(); - let end_line = fact["evidence"]["endLine"].as_u64(); - let Some(relative_path) = artifact["relativePath"].as_str() else { + && 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 cited_line = start_line - .filter(|line| Some(*line) == end_line && *line > 0) - .and_then(|line| { - std::fs::read_to_string(scenario_dir.join(relative_path)) - .ok()? - .lines() - .nth((line - 1) as usize) - .map(str::to_owned) - }); - let direct_markers = [ - format!( - "UpdateId={{{}}}", - fact["updateId"].as_str().unwrap_or_default() - ), - format!("CiId={}", fact["ciId"].as_str().unwrap_or_default()), - format!( - "ContentId={}", - fact["contentId"].as_str().unwrap_or_default() - ), - format!( - "UpdateJobId={}", - fact["updateJobId"].as_str().unwrap_or_default() - ), - format!( - "ClientHandle={}", - fact["clientHandle"].as_str().unwrap_or_default() - ), - format!("SiteCode={}", fact["siteCode"].as_str().unwrap_or_default()), - format!( - "SupHostHandle={}", - fact["supHostHandle"].as_str().unwrap_or_default() - ), - ]; - if !is_complete_location_source - || cited_line.as_deref().is_none_or(|line| { - !line.contains("LocateSup selected") - || direct_markers.iter().any(|marker| !line.contains(marker)) + } + 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}: counterpart fact needs explicit LocationServices LocateSup evidence" + "{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" )); } } @@ -736,19 +1538,48 @@ fn counterpart_source_failures( 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 = artifact["rotation"]["fragmentComplete"] - .as_bool() - .unwrap_or(false); + 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 && 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 + ) && fragment_complete == Some(true) { failures.push(format!( "{artifact_id}: {state} coverage cannot claim a complete fragment" @@ -762,18 +1593,7 @@ fn manifest_artifact_failures(scenario_dir: &Path, artifact: &Value) -> Vec Vec 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}" @@ -851,20 +1677,6 @@ fn manifest_artifact_failures(scenario_dir: &Path, artifact: &Value) -> Vec bool { - let memberships = artifact["designOnlyCatalog"]["groupMemberships"] - .as_array() - .expect("groupMemberships must be an array"); - if !memberships.iter().any(|value| value == logical_id) { - return false; - } - if state == "partial" { - artifact["captureState"] == "captured" && artifact["rotation"]["fragmentComplete"] == false - } else { - artifact["captureState"] == state - } -} - fn visit_files(root: &Path, files: &mut Vec) { if !root.exists() { return; @@ -910,6 +1722,101 @@ fn fnv1a64(bytes: &[u8], mut hash: u64) -> u64 { 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(); @@ -928,55 +1835,11 @@ fn software_update_fixture_matrix_pins_independent_conservative_outcomes() { let manifest = read_json(&scenario_dir.join("manifest.json")); let expected = read_json(&scenario_dir.join("expected.json")); - 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!( - "{}: manifest identity/proposal boundary drifted", - contract.name - )); - } - - let artifacts = manifest["artifacts"] - .as_array() - .expect("manifest artifacts must be an array"); - let artifact_ids = artifacts - .iter() - .map(|artifact| json_string(artifact, "artifactId")) - .collect::>(); - let mut sorted_artifact_ids = artifact_ids.clone(); - sorted_artifact_ids.sort(); - if artifact_ids != sorted_artifact_ids { - failures.push(format!( - "{}: manifest artifacts must be sorted by artifactId", - contract.name - )); - } - if artifact_ids.iter().collect::>().len() != artifact_ids.len() { - failures.push(format!("{}: artifact IDs must be unique", contract.name)); - } - - for (logical_id, state) in contract.coverage { - if !artifacts - .iter() - .any(|artifact| artifact_matches_coverage(artifact, logical_id, state)) - { - failures.push(format!( - "{}: manifest does not support expected coverage {logical_id}={state}", - contract.name - )); - } - } - failures.extend(expected_boundary_failures(&expected, contract)); - failures.extend(counterpart_source_failures( + failures.extend(scenario_semantic_failures( &scenario_dir, &manifest, &expected, + contract, )); } @@ -990,12 +1853,17 @@ fn software_update_fixture_bytes_paths_lines_and_ccm_records_are_exact() { let mut declared_files = BTreeSet::new(); let mut physical_files = Vec::new(); let mut corpus_items = Vec::new(); - let mut line_counts = BTreeMap::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"); @@ -1016,6 +1884,7 @@ fn software_update_fixture_bytes_paths_lines_and_ccm_records_are_exact() { 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") @@ -1056,7 +1925,15 @@ fn software_update_fixture_bytes_paths_lines_and_ccm_records_are_exact() { } } 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 = @@ -1074,6 +1951,7 @@ fn software_update_fixture_bytes_paths_lines_and_ccm_records_are_exact() { 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", @@ -1130,8 +2008,32 @@ fn software_update_fixture_bytes_paths_lines_and_ccm_records_are_exact() { 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() @@ -1141,15 +2043,34 @@ fn software_update_fixture_bytes_paths_lines_and_ccm_records_are_exact() { fnv1a64(bytes, hash) }); assert_eq!( - corpus_hash, EXPECTED_CORPUS_FNV1A64, - "#323 path-qualified evidence corpus drifted" + 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); + 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"] @@ -1169,6 +2090,29 @@ fn software_update_fixture_bytes_paths_lines_and_ccm_records_are_exact() { 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")); } @@ -1178,7 +2122,9 @@ 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_ne!(wrong_site["bundle"]["siteCode"], "LAB"); + 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")); @@ -1266,3 +2212,173 @@ fn software_update_fixture_contract_rejects_coverage_and_causality_mutations() { .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", + ); +} diff --git a/docs/sccm/preparation/issue-323-client-updates-corpus.md b/docs/sccm/preparation/issue-323-client-updates-corpus.md index 6cc276b11..3125ca105 100644 --- a/docs/sccm/preparation/issue-323-client-updates-corpus.md +++ b/docs/sccm/preparation/issue-323-client-updates-corpus.md @@ -92,6 +92,11 @@ 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. + ## Supplemental servicing boundary CBS, DISM, Windows Update, and ReportingEvents evidence remains separately @@ -157,6 +162,9 @@ stable, reviewed source facts and #333 defines the pairwise contract. 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. The corpus contains: @@ -201,5 +209,5 @@ 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, +`#323` must remain open for production implementation, shared-interface review, and eventual authorized development-client validation. From 94fd08d3e5e1f7024d6fc78335d2064c96639852 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 19:36:26 -0400 Subject: [PATCH 022/422] test(sccm): prepare task sequence corpus Refs #324 --- .../full-os/current/smsts.log | 1 + .../client-install-failure/expected.json | 37 + .../client-install-failure/manifest.json | 10 + .../client/current/smsts.log | 1 + .../client-installed/expected.json | 37 + .../client-installed/manifest.json | 10 + .../client/current/smsts.log | 1 + .../complete-looking-unkeyed/expected.json | 21 + .../complete-looking-unkeyed/manifest.json | 10 + .../client/completed/smsts.log | 1 + .../task_sequence/completed/expected.json | 35 + .../task_sequence/completed/manifest.json | 10 + .../setup/current/smsts.log | 1 + .../disk-image-failure/expected.json | 37 + .../disk-image-failure/manifest.json | 10 + .../task_sequence/incomplete/expected.json | 17 + .../task_sequence/incomplete/manifest.json | 10 + .../client/current/smsts.log | 1 + .../invalid-offset/expected.json | 37 + .../invalid-offset/manifest.json | 10 + .../setup/current/smsts.log | 1 + .../task_sequence/post-format/expected.json | 37 + .../task_sequence/post-format/manifest.json | 10 + .../full-os/current/smsts.log | 1 + .../task_sequence/pre-client/expected.json | 37 + .../task_sequence/pre-client/manifest.json | 10 + .../client/current/smsts.log | 1 + .../reboot-continuation/expected.json | 37 + .../reboot-continuation/manifest.json | 10 + .../client/completed/smsts.log | 1 + .../full-os/current/smsts.log | 1 + .../setup/current/smsts.log | 1 + .../winpe/current/smsts.log | 1 + .../relocated-fragments/expected.json | 48 + .../relocated-fragments/manifest.json | 13 + .../client/current/smsts.log | 1 + .../client/lo/smsts.lo_ | 1 + .../rotation-boundary/expected.json | 23 + .../rotation-boundary/manifest.json | 11 + .../client/current/smsts.log | 1 + .../software-install-failure/expected.json | 37 + .../software-install-failure/manifest.json | 10 + .../winpe/current/smsts.log | 1 + .../terminal-preflight/expected.json | 37 + .../terminal-preflight/manifest.json | 10 + .../unknown/current/smsts.log | 1 + .../unknown-profile/expected.json | 21 + .../unknown-profile/manifest.json | 10 + .../client/root-a/current/smsts.log | 1 + .../client/root-b/current/smsts.log | 1 + .../unrelated-runs/expected.json | 56 + .../unrelated-runs/manifest.json | 11 + .../winpe/current/smsts.log | 1 + .../client/task_sequence/winpe/expected.json | 37 + .../client/task_sequence/winpe/manifest.json | 10 + ...m_client_task_sequence_fixture_contract.rs | 1690 +++++++++++++++++ .../issue-324-client-task-sequence-corpus.md | 262 +++ 57 files changed, 2739 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-install-failure/evidence/client-task-sequence-smsts/full-os/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-install-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-install-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-installed/evidence/client-task-sequence-smsts/client/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-installed/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-installed/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/complete-looking-unkeyed/evidence/client-task-sequence-smsts/client/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/complete-looking-unkeyed/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/complete-looking-unkeyed/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/completed/evidence/client-task-sequence-smsts/client/completed/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/completed/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/completed/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/disk-image-failure/evidence/client-task-sequence-smsts/setup/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/disk-image-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/disk-image-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/incomplete/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/incomplete/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/invalid-offset/evidence/client-task-sequence-smsts/client/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/invalid-offset/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/invalid-offset/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/post-format/evidence/client-task-sequence-smsts/setup/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/post-format/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/post-format/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/pre-client/evidence/client-task-sequence-smsts/full-os/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/pre-client/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/pre-client/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/reboot-continuation/evidence/client-task-sequence-smsts/client/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/reboot-continuation/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/reboot-continuation/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/client/completed/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/full-os/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/setup/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/evidence/client-task-sequence-smsts/winpe/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/evidence/client-task-sequence-smsts/client/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/evidence/client-task-sequence-smsts/client/lo/smsts.lo_ create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/software-install-failure/evidence/client-task-sequence-smsts/client/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/software-install-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/software-install-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/terminal-preflight/evidence/client-task-sequence-smsts/winpe/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/terminal-preflight/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/terminal-preflight/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unknown-profile/evidence/client-task-sequence-smsts/unknown/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unknown-profile/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unknown-profile/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/evidence/client-task-sequence-smsts/client/root-a/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/evidence/client-task-sequence-smsts/client/root-b/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unrelated-runs/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/winpe/evidence/client-task-sequence-smsts/winpe/current/smsts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/winpe/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/winpe/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs create mode 100644 docs/sccm/preparation/issue-324-client-task-sequence-corpus.md 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..0749e0743 --- /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"]} +} 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..2c7fd2854 --- /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"]} +} 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..079125817 --- /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"]} +} 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..f2e7f8e84 --- /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"]} +} 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..5d3a68822 --- /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"]} +} 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..36a346361 --- /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"]} +} 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..d5de55c2b --- /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","fragmentComplete":false},"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..8a4354df3 --- /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"]} +} 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..3edd583ed --- /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"]} +} 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..7e7a9960d --- /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"]} +} 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..cb1058925 --- /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"]} +} 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..002f8a542 --- /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"],"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..28f8b5102 --- /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"],"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..06c072d9b --- /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"]} +} 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..ca8db196e --- /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"]} +} 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..428df3cbc --- /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"],"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..71cb4336a --- /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"]} +} 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/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..1afb24fa6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -0,0 +1,1690 @@ +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 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())) +} + +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 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() { + 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: SCENARIOS.len(), + artifacts, + evidence_files, + evidence_bytes, + evidence_lines, + capture_states, + digest: hex_digest(&sha256(digest_rows.concat().as_bytes())), + } +} + +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 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 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://") + || artifact["smstsLogPathEvidence"] != sanitized_path + { + return Err(format!( + "{scenario}/{artifact_id}: _SMSTSLogPath provenance is not bound" + )); + } + let contents = std::fs::read_to_string(&fixture_path) + .map_err(|error| format!("{relative_path} is not UTF-8: {error}"))?; + if artifact["rotation"]["fragmentComplete"] == true + && !contents.contains("SYNTHETIC FIXTURE") + { + return Err(format!( + "{scenario}/{artifact_id}: complete evidence lacks synthetic marker" + )); + } + } 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" + )); + } + } + + 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)?; + 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 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 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" + )); + } + + 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 [ + "executionId", + "taskSequencePackageId", + "advertisementId", + "runContext", + ] { + if !key.get(required).is_some_and(Value::is_string) { + return Err(format!( + "{transaction_id}: missing exact key field {required}" + )); + } + } + for forbidden in ["filename", "path", "timestamp", "displayName", "component"] { + if key.contains_key(forbidden) { + return Err(format!( + "{transaction_id}: forbidden join field {forbidden}" + )); + } + } + if key.get("confidence").and_then(Value::as_str) != Some("exact") + || key.get("extractionProfileId").and_then(Value::as_str) + != Some("task-sequence-client-5.00.test-v1") + { + 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 mut cited_text = String::new(); + for evidence_ref in evidence_refs { + cited_text.push_str(&evidence_text( + scenario_root, + &artifacts_by_id, + evidence_ref, + )?); + cited_text.push('\n'); + } + for (field, value) in key { + if matches!( + field.as_str(), + "keyProfileKind" | "confidence" | "extractionProfileId" + ) { + continue; + } + let value = value + .as_str() + .ok_or_else(|| format!("{transaction_id}: key {field} is not a string"))?; + let needle = format!("{field}={value}"); + if !cited_text.contains(&needle) { + return Err(format!( + "{transaction_id}: key {field} is not bound to cited evidence ({needle})" + )); + } + } + + 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_text.contains(&format!("phase={phase}")) + || !cited_text.contains(&format!("state={state}")) + { + return Err(format!( + "{transaction_id}: phase/state semantics are not bound to cited evidence" + )); + } + + let mut expected_path_sequence = Vec::new(); + for path_item in transaction["pathSequence"] + .as_array() + .ok_or_else(|| format!("{transaction_id}: pathSequence is not an array"))? + { + let artifact_id = path_item["artifactId"] + .as_str() + .ok_or_else(|| format!("{transaction_id}: path artifactId is missing"))?; + let artifact = artifacts_by_id + .get(artifact_id) + .ok_or_else(|| format!("{transaction_id}: unknown path artifact {artifact_id}"))?; + if path_item["pathClass"] != artifact["pathClass"] + || path_item["relocationOrdinal"] != artifact["relocationOrdinal"] + { + return Err(format!( + "{transaction_id}: path provenance does not match {artifact_id}" + )); + } + expected_path_sequence.push(( + path_item["relocationOrdinal"] + .as_u64() + .ok_or_else(|| format!("{transaction_id}: relocationOrdinal is missing"))?, + artifact_id.to_owned(), + )); + } + let mut sorted_path_sequence = expected_path_sequence.clone(); + sorted_path_sequence.sort(); + if expected_path_sequence != sorted_path_sequence { + return Err(format!( + "{transaction_id}: path sequence is not deterministic" + )); + } + + let timestamp = &transaction["timestampProvenance"]; + let ordering_ref = &transaction["orderingEvidence"]; + 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::Secs, 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 let Some(next_artifact) = transaction["nextArtifact"].as_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" + )); + } + } + + if transaction["classification"] == "confirmedFailure" { + if transaction["state"] != "failed" || transaction["terminalEvidence"].is_null() { + return Err(format!( + "{transaction_id}: confirmed failure lacks terminal evidence" + )); + } + let terminal_text = evidence_text( + scenario_root, + &artifacts_by_id, + &transaction["terminalEvidence"], + )?; + if !terminal_text.contains("terminal=true") || !terminal_text.contains("state=failed") { + return Err(format!( + "{transaction_id}: terminal citation is not a terminal failure record" + )); + } + } + } + + 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 { + return Err(format!( + "{scenario}: source-local observations are not 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")); + } + 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 { + return Err(format!("{scenario}: finding IDs are not 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 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" + )); + } + 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 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}"); +} 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..0d049e8bd --- /dev/null +++ b/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md @@ -0,0 +1,262 @@ +# 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 source path; +- the `_SMSTSLogPath` value observed in the cited record; +- a path class and relocation ordinal; +- the source version, capture timestamp, encoding, and exact byte count; and +- whether that physical fragment is a complete logical CCM record. + +`_SMSTSLogPath` is the authoritative path observation. A filename, display +name, timestamp, directory name, or assumed operating-system stage cannot +invent relocation 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. 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 +smallest bounded next `client-task-sequence-smsts` path class to collect. + +## 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. Until the +final intake interfaces define controlled logical reconstruction, both remain +low-confidence, non-correlatable source-local observations. + +## Coverage semantics + +Capture state and execution state are independent: + +- `captured` means the physical artifact was available and complete; +- `partial` means only incomplete rotation fragments are available; and +- `absent` means the logical artifact was not captured. + +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 sorted rows as +`scenario NUL artifactId NUL relativePath NUL fileSha256 LF`, and hashes the +concatenated rows. 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; +- 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; +- 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. From 6784259fa031ce2287934317b77afe665ae5fe44 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 19:37:34 -0400 Subject: [PATCH 023/422] test(sccm): cap experimental update key confidence --- .../fixtures/sccm/client/updates/README.md | 2 + .../sccm_client_updates_fixture_contract.rs | 79 ++++++++++++++++++- .../issue-323-client-updates-corpus.md | 4 + 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md index 2e7153a51..3cf16b5f5 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md @@ -18,6 +18,8 @@ Every scenario contains: 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 diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index af5217514..16a7b171d 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -3,8 +3,9 @@ use cmtraceopen_parser::{ models::log_entry::LogFormat, parser::{parse_content_with_selection, ResolvedParser}, sccm::{ - normalize_ccm_artifact, SccmArtifact, SccmCoverageState, SccmEvidence, SccmRole, - SccmRotation, SccmTimeOrderingState, + extract_keys, normalize_ccm_artifact, SccmArtifact, SccmCoverageState, SccmEvidence, + SccmExtractionGapKind, SccmExtractionProfile, SccmKeyConfidence, SccmRole, SccmRotation, + SccmTimeOrderingState, SCCM_EXPERIMENTAL_KEY_PROFILE_ID, }, }; use serde_json::Value; @@ -1138,6 +1139,42 @@ fn conservative_outcome_failures(scenario: &str, expected: &Value) -> Vec 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 manifest_artifact_kind_failures(artifact: &Value) -> Vec { let artifact_id = artifact["artifactId"].as_str().unwrap_or(""); let group = artifact["designOnlyCatalog"]["entryId"].as_str(); @@ -1407,6 +1444,7 @@ fn manifest_expected_binding_failures( } failures.extend(finding_binding_failures(scenario, expected)); failures.extend(conservative_outcome_failures(scenario, expected)); + failures.extend(experimental_profile_causality_failures(expected)); failures } @@ -2382,3 +2420,40 @@ fn software_update_fixture_contract_rejects_review_adversarial_mutations() { "nonphysical rotation fragmentComplete", ); } + +#[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"))); +} diff --git a/docs/sccm/preparation/issue-323-client-updates-corpus.md b/docs/sccm/preparation/issue-323-client-updates-corpus.md index 3125ca105..ce7d0d60d 100644 --- a/docs/sccm/preparation/issue-323-client-updates-corpus.md +++ b/docs/sccm/preparation/issue-323-client-updates-corpus.md @@ -69,6 +69,10 @@ The only selected preparation profile is `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: From b880179199c89a5b7b67686e7fe0e1a069dfc0a2 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 19:43:30 -0400 Subject: [PATCH 024/422] test(sccm): reject mixed task sequence keys Refs #324 --- ...m_client_task_sequence_fixture_contract.rs | 104 ++++++++++++++---- .../issue-324-client-task-sequence-corpus.md | 7 +- 2 files changed, 87 insertions(+), 24 deletions(-) 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 index 1afb24fa6..8a8f636d2 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -857,31 +857,52 @@ fn validate_contract( let evidence_refs = transaction["evidence"] .as_array() .ok_or_else(|| format!("{transaction_id}: evidence is not an array"))?; - let mut cited_text = String::new(); + 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_texts = Vec::new(); for evidence_ref in evidence_refs { - cited_text.push_str(&evidence_text( - scenario_root, - &artifacts_by_id, - evidence_ref, - )?); - cited_text.push('\n'); - } - for (field, value) in key { - if matches!( - field.as_str(), - "keyProfileKind" | "confidence" | "extractionProfileId" - ) { - continue; + 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 value = value - .as_str() - .ok_or_else(|| format!("{transaction_id}: key {field} is not a string"))?; - let needle = format!("{field}={value}"); - if !cited_text.contains(&needle) { + + let record_text = evidence_text(scenario_root, &artifacts_by_id, evidence_ref)?; + if let Some(missing_needle) = key_needles + .iter() + .find(|needle| !record_text.contains(needle.as_str())) + { return Err(format!( - "{transaction_id}: key {field} is not bound to cited evidence ({needle})" + "{transaction_id}: declared key fields do not co-occur in cited complete CCM record ({missing_needle})" )); } + cited_record_texts.push(record_text); } let phase = transaction["phase"] @@ -896,8 +917,10 @@ fn validate_contract( if !STATE_CHAIN.contains(&phase) || !STATE_CHAIN.contains(&last_successful_phase) || !["inProgress", "blockedOrDeferred", "failed", "succeeded"].contains(&state) - || !cited_text.contains(&format!("phase={phase}")) - || !cited_text.contains(&format!("state={state}")) + || !cited_record_texts.iter().any(|record_text| { + record_text.contains(&format!("phase={phase}")) + && record_text.contains(&format!("state={state}")) + }) { return Err(format!( "{transaction_id}: phase/state semantics are not bound to cited evidence" @@ -915,6 +938,14 @@ fn validate_contract( let artifact = artifacts_by_id .get(artifact_id) .ok_or_else(|| format!("{transaction_id}: unknown path artifact {artifact_id}"))?; + if !evidence_refs + .iter() + .any(|evidence_ref| evidence_ref["artifactId"] == artifact_id) + { + return Err(format!( + "{transaction_id}: path artifact {artifact_id} is not key-bound cited evidence" + )); + } if path_item["pathClass"] != artifact["pathClass"] || path_item["relocationOrdinal"] != artifact["relocationOrdinal"] { @@ -939,6 +970,14 @@ fn validate_contract( let timestamp = &transaction["timestampProvenance"]; let ordering_ref = &transaction["orderingEvidence"]; + if !evidence_refs + .iter() + .any(|evidence_ref| 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"] @@ -1010,6 +1049,14 @@ fn validate_contract( "{transaction_id}: confirmed failure lacks terminal evidence" )); } + if !evidence_refs + .iter() + .any(|evidence_ref| 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, @@ -1630,6 +1677,19 @@ fn adversarial_contract_mutations_fail_closed() { .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(); diff --git a/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md b/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md index 0d049e8bd..a4504f35d 100644 --- a/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md +++ b/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md @@ -81,8 +81,10 @@ An exact synthetic transaction key contains all of: 4. `runContext`. Every field must be present in the transaction's cited evidence under the -recognized profile. Filename, path, timestamp, display name, component, or -ingestion order are forbidden join fields. +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, @@ -204,6 +206,7 @@ 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; From 8612c172a827fb663d6572074dcf9b2c08c91519 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 19:51:44 -0400 Subject: [PATCH 025/422] feat(sccm): enforce evidence-backed findings --- .../cmtraceopen-parser/src/sccm/findings.rs | 748 ++++++++++++++++ crates/cmtraceopen-parser/src/sccm/mod.rs | 2 + .../tests/sccm_spine_contract.rs | 846 +++++++++++++++++- 3 files changed, 1590 insertions(+), 6 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/findings.rs diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs new file mode 100644 index 000000000..f386c244f --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -0,0 +1,748 @@ +use std::cmp::Ordering; + +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::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; +const MAX_SCCM_COVERAGE_GAP_ARTIFACT_ID_CHARS: usize = 256; +const REGISTERED_STABLE_CORRELATION_PROFILE_IDS: &[&str] = &[]; + +#[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, + } + } +} + +impl Serialize for SccmPhase { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if matches!(self, Self::Unknown(value) if is_known_phase_name(value)) { + return Err(S::Error::custom( + "unknown SCCM phase 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>, + { + Ok(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), + }) + } +} + +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, + } + } +} + +impl Serialize for SccmTerminalEvidenceKind { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if matches!(self, Self::Unknown(value) if value == "observedFailure") { + return Err(S::Error::custom( + "unknown terminal evidence kind 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>, + { + Ok(match String::deserialize(deserializer)? { + value if value == "observedFailure" => Self::ObservedFailure, + value => Self::Unknown(value), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +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, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmFindingCoverageGap { + pub artifact_id: String, + pub role: SccmRole, + pub coverage: SccmCoverageState, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmArtifactRequest { + pub logical_id: String, + pub role: SccmRole, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +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(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, +} + +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, + terminal_evidence: wire.terminal_evidence, + coverage_gaps: wire.coverage_gaps, + correlation_keys: wire.correlation_keys, + next_artifacts: wire.next_artifacts, + }; + 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_required_text(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() { + 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, + MissingEvidenceOrCoverageGap, + MissingTerminalEvidence, + InvalidTerminalEvidence, + TerminalEvidenceNotCited, + CorrelationKeyMissingEvidence, + CorrelationKeyEvidenceNotCited, + 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, + }; + normalize_finding(&mut finding); + finding.validate()?; + Ok(finding) + } +} + +fn validate_required_text(finding: &SccmFinding) -> Result<(), SccmFindingValidationError> { + if finding.finding_id.trim().is_empty() + || finding.title.trim().is_empty() + || finding.summary.trim().is_empty() + || matches!(&finding.phase, SccmPhase::Unknown(value) if value.trim().is_empty()) + { + return Err(SccmFindingValidationError::MissingRequiredField); + } + Ok(()) +} + +fn validate_coverage_gaps( + coverage_gaps: &[SccmFindingCoverageGap], +) -> Result<(), SccmFindingValidationError> { + if coverage_gaps.iter().any(|gap| { + gap.artifact_id.trim().is_empty() + || gap.artifact_id.chars().count() > MAX_SCCM_COVERAGE_GAP_ARTIFACT_ID_CHARS + || gap.coverage == SccmCoverageState::Captured + }) { + return Err(SccmFindingValidationError::InvalidCoverageGap); + } + 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_bounded_request_reason(&request.reason) { + return Err(SccmFindingValidationError::InvalidArtifactRequestReason); + } + + 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); + }; + if first_match.role != request.role + && !logical_matches.any(|entry| entry.role == request.role) + { + return Err(SccmFindingValidationError::ArtifactRequestRoleMismatch); + } + } + Ok(()) +} + +fn is_bounded_request_reason(reason: &str) -> bool { + let trimmed = reason.trim(); + if trimmed.is_empty() + || trimmed.chars().count() > MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS + || trimmed.contains(['*', '?', '[', ']']) + { + return false; + } + + let lowercase = trimmed.to_ascii_lowercase(); + ![ + "entire drive", + "whole drive", + "drive root", + "root drive", + "entire disk", + "whole disk", + "all files", + "recursive", + ] + .iter() + .any(|unbounded| lowercase.contains(unbounded)) +} + +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 Some(reference) = &key.evidence else { + return Err(SccmFindingValidationError::CorrelationKeyMissingEvidence); + }; + 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 normalize_finding(finding: &mut SccmFinding) { + finding.finding_id = finding.finding_id.trim().to_owned(); + finding.title = finding.title.trim().to_owned(); + finding.summary = finding.summary.trim().to_owned(); + for gap in &mut finding.coverage_gaps { + gap.artifact_id = gap.artifact_id.trim().to_owned(); + } + for request in &mut finding.next_artifacts { + request.logical_id = request.logical_id.trim().to_owned(); + 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::ClientGuid => 1, + SccmCorrelationKeyKind::PackageId => 2, + SccmCorrelationKeyKind::ContentId => 3, + SccmCorrelationKeyKind::SiteCode => 4, + SccmCorrelationKeyKind::ServerHost => 5, + SccmCorrelationKeyKind::CiId => 6, + SccmCorrelationKeyKind::UpdateId => 7, + SccmCorrelationKeyKind::KbId => 8, + SccmCorrelationKeyKind::BitsJobId => 9, + SccmCorrelationKeyKind::TaskSequenceExecutionId => 10, + SccmCorrelationKeyKind::RequestId => 11, + SccmCorrelationKeyKind::TopicId => 12, + SccmCorrelationKeyKind::StateMessageId => 13, + } +} + +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/mod.rs b/crates/cmtraceopen-parser/src/sccm/mod.rs index d4c45f4ea..d5f1b55d3 100644 --- a/crates/cmtraceopen-parser/src/sccm/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/mod.rs @@ -1,5 +1,6 @@ pub mod catalog; mod evidence; +mod findings; mod ingest; mod keys; pub mod models; @@ -7,6 +8,7 @@ mod rotation; mod signals; pub use catalog::*; +pub use findings::*; pub use ingest::*; pub use keys::*; pub use models::*; diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index db08918c4..6da993790 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -1,12 +1,16 @@ -use cmtraceopen_parser::models::log_entry::{LogFormat, ParserKind}; +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, - SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, SccmEvidenceRef, - SccmExtractionGapKind, SccmExtractionProfile, SccmExtractionProfileMaturity, SccmFindingClass, - SccmKeyConfidence, SccmKeyExtractionResult, SccmRole, SccmRotation, SccmSignal, SccmSignalKind, - SccmTimeOrderingState, SccmTimestamp, SccmUnknownRotation, SCCM_DIAGNOSTICS_SCHEMA_VERSION, + 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 { @@ -47,6 +51,836 @@ fn evidence_with_message(message: &str) -> SccmEvidence { } } +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, + } +} + +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(), + } +} + +#[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_forged_unregistered_profile_never_authorizes_high_corroboration() { + 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::MissingTerminalEvidence + ); +} + +#[test] +fn finding_duplicate_one_ref_never_counts_as_two_ref_corroboration() { + 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::MissingTerminalEvidence + ); +} + +#[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_mismatched_keys_or_profiles_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 = [ + ( + "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::MissingTerminalEvidence, + "{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"); + let missing = 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_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_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-keys", + Some("sccm-keys-experimental-v1"), + SccmKeyConfidence::Low, + ), + ("unprofiled-keys", None, SccmKeyConfidence::Exact), + ]; + + 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_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_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_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_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_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), From 5fd80ee9f97aa986708f9d8ea27da14111934602 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 20:01:33 -0400 Subject: [PATCH 026/422] fix(sccm): reject nested finding wire fields --- .../cmtraceopen-parser/src/sccm/findings.rs | 123 ++++++++++++++++-- .../tests/sccm_spine_contract.rs | 100 ++++++++++++++ 2 files changed, 213 insertions(+), 10 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index f386c244f..90643801b 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -15,6 +15,8 @@ use super::models::{ pub const MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS: usize = 240; pub const MAX_SCCM_NEXT_ARTIFACT_REQUESTS: usize = 16; const MAX_SCCM_COVERAGE_GAP_ARTIFACT_ID_CHARS: usize = 256; +// Intentionally empty: no extraction profile is verified as stable enough to +// authorize key-only High confidence. Adding one requires contract review. const REGISTERED_STABLE_CORRELATION_PROFILE_IDS: &[&str] = &[]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -179,11 +181,111 @@ struct SccmFindingWire { confidence: SccmConfidence, title: String, summary: String, - evidence: Vec, - terminal_evidence: Vec, - coverage_gaps: Vec, - correlation_keys: Vec, - next_artifacts: Vec, + evidence: Vec, + terminal_evidence: Vec, + coverage_gaps: Vec, + correlation_keys: Vec, + next_artifacts: Vec, +} + +#[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, + } + } +} + +#[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, + } + } +} + +#[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, + } + } +} + +#[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, + } + } +} + +#[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, + } + } } impl<'de> Deserialize<'de> for SccmFinding { @@ -204,11 +306,11 @@ impl<'de> Deserialize<'de> for SccmFinding { confidence: wire.confidence, title: wire.title, summary: wire.summary, - evidence: wire.evidence, - terminal_evidence: wire.terminal_evidence, - coverage_gaps: wire.coverage_gaps, - correlation_keys: wire.correlation_keys, - next_artifacts: wire.next_artifacts, + 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(), }; normalize_finding(&mut finding); finding.validate().map_err(|error| { @@ -430,6 +532,7 @@ fn validate_required_text(finding: &SccmFinding) -> Result<(), SccmFindingValida || finding.title.trim().is_empty() || finding.summary.trim().is_empty() || matches!(&finding.phase, SccmPhase::Unknown(value) if value.trim().is_empty()) + || matches!(&finding.phase, SccmPhase::Unknown(value) if is_known_phase_name(value)) { return Err(SccmFindingValidationError::MissingRequiredField); } diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 6da993790..2902693ec 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -160,6 +160,29 @@ fn finding_high_confirmed_failure_accepts_a_cited_terminal_failure() { ); } +#[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_never_authorizes_high_corroboration() { let first = finding_evidence_ref("client-policy-agent", "policy:10-10"); @@ -775,6 +798,83 @@ fn finding_deserialization_rejects_raw_execution_context_fields() { 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"); From 89a87b54648223839bec126a8ee5cbfa59575c82 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 20:03:30 -0400 Subject: [PATCH 027/422] fix(sccm): close update fixture semantic gaps --- .../fixtures/sccm/client/updates/README.md | 8 +- .../sccm_client_updates_fixture_contract.rs | 450 ++++++++++++++++++ .../issue-323-client-updates-corpus.md | 9 + 3 files changed, 466 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md index 3cf16b5f5..ce7bb1f36 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md @@ -50,10 +50,16 @@ normalized cited CCM record; an unavailable SUP handle remains `null`. They remain client facts for future #330/#333 work. Time alone is never eligible, topology is not evaluated here, 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. +evidence. Client role/catalog/group/basename/rotation/path identities are +coherent, and physical paths/fingerprints cannot alias another artifact. ## Replay diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 16a7b171d..0336cbdd6 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -902,6 +902,100 @@ fn exact_message_field<'a>(message: &'a str, field: &str) -> Option<&'a str> { }) } +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"]) + } + _ => false, + } +} + fn expected_transaction_gaps(scenario: &str) -> &'static [&'static str] { match scenario { "access-denied" => &["client-updates"], @@ -972,6 +1066,14 @@ fn transaction_binding_failures( )); } } + 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" @@ -1012,6 +1114,29 @@ fn transaction_binding_failures( )), } + let requires_phase_outcome = transaction["confidence"] == "high" + && transaction["confidenceCeiling"] == "high" + && matches!( + ( + transaction["classification"].as_str(), + transaction["state"].as_str() + ), + (Some("success"), Some("succeeded")) | (Some("confirmedFailure"), Some("failed")) + ); + 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 outcome evidence is missing for {transaction_id}" + )); + } + let actual_gaps = string_array(transaction, "coverageGapArtifactIds"); if actual_gaps != expected_transaction_gaps(scenario) { failures.push(format!( @@ -1175,6 +1300,79 @@ fn experimental_profile_causality_failures(expected: &Value) -> Vec { 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 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")); + } + + let basename = artifact["originalBasename"].as_str(); + 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(); @@ -1375,12 +1573,38 @@ fn manifest_expected_binding_failures( "{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() @@ -1473,6 +1697,11 @@ fn counterpart_source_failures( ]; for fact in facts { + if fact["topologyCompatible"] == false && fact["correlationEligible"] != false { + failures.push( + "counterpart topology mismatch cannot remain correlation eligible".to_owned(), + ); + } if fact["keyConfidence"] != "exact" || fact["correlationEligible"] != true || fact["timeOnlyEligible"] != false @@ -2421,6 +2650,227 @@ fn software_update_fixture_contract_rejects_review_adversarial_mutations() { ); } +#[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_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_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); + 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"); diff --git a/docs/sccm/preparation/issue-323-client-updates-corpus.md b/docs/sccm/preparation/issue-323-client-updates-corpus.md index ce7d0d60d..0e41d590c 100644 --- a/docs/sccm/preparation/issue-323-client-updates-corpus.md +++ b/docs/sccm/preparation/issue-323-client-updates-corpus.md @@ -91,6 +91,11 @@ 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. A High success or +confirmed failure additionally requires a compatible source record containing +the exact key plus the claimed phase disposition/terminal marker. + 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 @@ -154,6 +159,7 @@ The handoff explicitly records: - #333 owns any future pairwise correlation; - time alone is never eligible; - topology compatibility is not evaluated here; +- an explicitly incompatible topology is never correlation-eligible; - bundle capture host is not SUP evidence; - no server cause is claimed; and - missing/unvalidated client source evidence emits no counterpart-ready fact. @@ -169,6 +175,9 @@ 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. The corpus contains: From dddcd03ecb532875981d90d71a181d0c06115c1a Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 20:09:36 -0400 Subject: [PATCH 028/422] fix(sccm): validate finding serialization --- .../cmtraceopen-parser/src/sccm/findings.rs | 48 ++++++++++++++++++- .../tests/sccm_spine_contract.rs | 18 +++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 90643801b..7ab317f8b 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -152,8 +152,7 @@ pub struct SccmArtifactRequest { pub reason: String, } -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, PartialEq)] pub struct SccmFinding { pub finding_id: String, pub class: SccmFindingClass, @@ -170,6 +169,51 @@ pub struct SccmFinding { 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:?}")) + })?; + SccmFindingSerializeWire { + finding_id: &self.finding_id, + class: &self.class, + phase: &self.phase, + role: &self.role, + severity: &self.severity, + confidence: &self.confidence, + 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, + } + .serialize(serializer) + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct SccmFindingWire { diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 2902693ec..9317c1958 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -746,6 +746,24 @@ fn finding_deserialization_rejects_unsound_high_and_forged_terminal_state() { assert!(serde_json::from_value::(forged_terminal).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"); From 6f5f2d2e4341feace86e422008f397fd519bd353 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 20:13:52 -0400 Subject: [PATCH 029/422] test(sccm): bind task sequence contract evidence Refs #324 --- ...m_client_task_sequence_fixture_contract.rs | 573 +++++++++++++++++- 1 file changed, 545 insertions(+), 28 deletions(-) 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 index 8a8f636d2..751a66999 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -346,6 +346,31 @@ fn artifact_effective_state(artifact: &Value) -> Result { } } +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() +} + +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()); @@ -478,6 +503,8 @@ fn validate_manifest_and_storage( let mut referenced_files = BTreeSet::new(); let mut logical_states = BTreeMap::>::new(); let mut logical_paths = BTreeMap::>::new(); + let mut observed_paths_by_fingerprint = BTreeMap::>::new(); + let mut captured_path_claims = Vec::<(String, String, String, String)>::new(); for artifact in artifacts { let artifact_id = artifact["artifactId"] @@ -562,6 +589,14 @@ fn validate_manifest_and_storage( "{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"))?; @@ -618,13 +653,32 @@ fn validate_manifest_and_storage( } let contents = std::fs::read_to_string(&fixture_path) .map_err(|error| format!("{relative_path} is not UTF-8: {error}"))?; - if artifact["rotation"]["fragmentComplete"] == true - && !contents.contains("SYNTHETIC FIXTURE") - { + 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}: complete evidence lacks synthetic marker" + "{scenario}/{artifact_id}: fragmentComplete is not bound to physical CCM grammar" )); } + let fingerprint = path_fingerprint.to_owned(); + observed_paths_by_fingerprint + .entry(fingerprint.clone()) + .or_default() + .extend(smsts_log_paths(&contents)); + captured_path_claims.push(( + artifact_id.to_owned(), + fingerprint, + sanitized_path.to_owned(), + path_class.to_owned(), + )); } else if artifact["relativePath"].is_string() || artifact["sanitizedSourcePath"].is_string() || artifact["smstsLogPathEvidence"].is_string() @@ -635,6 +689,26 @@ fn validate_manifest_and_storage( return Err(format!( "{scenario}/{artifact_id}: noncapture artifact invents physical provenance" )); + } else if path_class != "unknown" { + return Err(format!( + "{scenario}/{artifact_id}: noncapture pathClass must remain unknown" + )); + } + } + + for (artifact_id, fingerprint, sanitized_path, path_class) in captured_path_claims { + let observed_paths = observed_paths_by_fingerprint + .get(&fingerprint) + .ok_or_else(|| format!("{scenario}/{artifact_id}: no _SMSTSLogPath evidence"))?; + if observed_paths.len() != 1 || !observed_paths.contains(&sanitized_path) { + return Err(format!( + "{scenario}/{artifact_id}: sanitized _SMSTSLogPath is not bound to physical evidence" + )); + } + if path_class_for_sanitized_path(&sanitized_path) != Some(path_class.as_str()) { + return Err(format!( + "{scenario}/{artifact_id}: pathClass is not bound to _SMSTSLogPath evidence" + )); } } @@ -692,6 +766,41 @@ fn validate_contract( }) .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() @@ -846,8 +955,8 @@ fn validate_contract( } } if key.get("confidence").and_then(Value::as_str) != Some("exact") - || key.get("extractionProfileId").and_then(Value::as_str) - != Some("task-sequence-client-5.00.test-v1") + || 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" @@ -927,25 +1036,32 @@ fn validate_contract( )); } - let mut expected_path_sequence = Vec::new(); - for path_item in transaction["pathSequence"] + let path_items = transaction["pathSequence"] .as_array() - .ok_or_else(|| format!("{transaction_id}: pathSequence is not an 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}"))?; - if !evidence_refs + let evidence_ref = evidence_refs .iter() - .any(|evidence_ref| evidence_ref["artifactId"] == artifact_id) - { - return Err(format!( - "{transaction_id}: path artifact {artifact_id} is not key-bound cited evidence" - )); - } + .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"] { @@ -953,20 +1069,71 @@ fn validate_contract( "{transaction_id}: path provenance does not match {artifact_id}" )); } - expected_path_sequence.push(( + 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 mut sorted_path_sequence = expected_path_sequence.clone(); - sorted_path_sequence.sort(); - if expected_path_sequence != sorted_path_sequence { + let declared_artifact_order = declared_path_sequence + .iter() + .map(|(_, artifact_id)| artifact_id.as_str()) + .collect::>(); + 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}: path sequence is not deterministic" + "{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(); + 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"]; @@ -1028,7 +1195,10 @@ fn validate_contract( )); } } - if let Some(next_artifact) = transaction["nextArtifact"].as_object() { + 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"] @@ -1043,10 +1213,63 @@ fn validate_contract( } } - if transaction["classification"] == "confirmedFailure" { - if transaction["state"] != "failed" || transaction["terminalEvidence"].is_null() { + 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}: confirmed failure lacks terminal evidence" + "{transaction_id}: terminal outcome lacks terminal evidence" )); } if !evidence_refs @@ -1062,9 +1285,12 @@ fn validate_contract( &artifacts_by_id, &transaction["terminalEvidence"], )?; - if !terminal_text.contains("terminal=true") || !terminal_text.contains("state=failed") { + if !terminal_text.contains("terminal=true") + || !terminal_text.contains(&format!("state={terminal_state}")) + || !terminal_text.contains(&format!("phase={phase}")) + { return Err(format!( - "{transaction_id}: terminal citation is not a terminal failure record" + "{transaction_id}: terminal citation does not prove the terminal outcome" )); } } @@ -1118,6 +1344,17 @@ fn validate_contract( 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"))?; @@ -1127,6 +1364,67 @@ fn validate_contract( "{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 outcome_is_transaction_bound = match classification { + "success" | "confirmedFailure" => transactions.iter().any(|transaction| { + transaction["classification"] == classification + && !transaction["terminalEvidence"].is_null() + && evidence + .iter() + .any(|evidence_ref| evidence_ref == &transaction["terminalEvidence"]) + }), + "blockedOrDeferred" => transactions.iter().any(|transaction| { + transaction["classification"] == "blockedOrDeferred" + && transaction["evidence"] + .as_array() + .is_some_and(|transaction_evidence| { + evidence.iter().any(|evidence_ref| { + transaction_evidence + .iter() + .any(|transaction_ref| transaction_ref == evidence_ref) + }) + }) + }), + "insufficientEvidence" => true, + _ => false, + }; + if !outcome_is_transaction_bound { + return Err(format!( + "{finding_id}: finding outcome is not bound to terminal/keyed transaction evidence" + )); + } if finding["serverCauseClaimed"] != false || finding["appOrPolicyCauseClaimed"] != false || finding["nativeAcceptanceClaimed"] != false @@ -1748,3 +2046,222 @@ fn adversarial_contract_mutations_fail_closed() { .expect_err("confirmed failure requires cited terminal evidence"); assert!(error.contains("terminal"), "{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(", ") + ); +} From e69eb1c073e912d1a65f8ce0debd4e904b0639cb Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 20:17:10 -0400 Subject: [PATCH 030/422] fix(sccm): validate finding evidence references --- .../cmtraceopen-parser/src/sccm/findings.rs | 43 +++++ .../tests/sccm_spine_contract.rs | 167 ++++++++++++++++++ 2 files changed, 210 insertions(+) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 7ab317f8b..8a7640925 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -367,6 +367,7 @@ impl<'de> Deserialize<'de> for SccmFinding { impl SccmFinding { pub fn validate(&self) -> Result<(), SccmFindingValidationError> { validate_required_text(self)?; + validate_all_evidence_references(self)?; validate_coverage_gaps(&self.coverage_gaps)?; validate_artifact_requests(&self.next_artifacts)?; @@ -412,6 +413,7 @@ impl SccmFinding { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SccmFindingValidationError { MissingRequiredField, + InvalidEvidenceReference, MissingEvidenceOrCoverageGap, MissingTerminalEvidence, InvalidTerminalEvidence, @@ -583,6 +585,47 @@ fn validate_required_text(finding: &SccmFinding) -> Result<(), SccmFindingValida Ok(()) } +fn validate_all_evidence_references( + finding: &SccmFinding, +) -> Result<(), SccmFindingValidationError> { + for reference in finding + .evidence + .iter() + .chain( + finding + .terminal_evidence + .iter() + .map(|terminal| &terminal.reference), + ) + .chain( + finding + .correlation_keys + .iter() + .filter_map(|key| key.evidence.as_ref()), + ) + { + validate_evidence_reference(reference)?; + } + 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 reference.artifact_id.trim().is_empty() + || reference.entry_id.trim().is_empty() + || !valid_line_range + { + return Err(SccmFindingValidationError::InvalidEvidenceReference); + } + Ok(()) +} + fn validate_coverage_gaps( coverage_gaps: &[SccmFindingCoverageGap], ) -> Result<(), SccmFindingValidationError> { diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 9317c1958..e1bbf2230 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -372,6 +372,106 @@ fn finding_rejects_key_or_terminal_refs_that_are_not_cited() { ); } +#[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_rejects_a_correlation_key_without_an_evidence_ref() { let cited = finding_evidence_ref("client-policy-agent", "policy:1-1"); @@ -746,6 +846,73 @@ fn finding_deserialization_rejects_unsound_high_and_forged_terminal_state() { 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"); From 45261f26254bf178dabd73f1d261ffd512c6b081 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 20:25:02 -0400 Subject: [PATCH 031/422] fix(sccm): close update provenance review gaps --- .../fixtures/sccm/client/updates/README.md | 9 +- .../sccm/client/updates/capped/expected.json | 2 +- .../updates/content-failure/expected.json | 2 +- .../updates/install-failure/expected.json | 2 +- .../updates/maintenance-window/expected.json | 2 +- .../updates/reboot-pending/expected.json | 2 +- .../updates/reporting-failure/expected.json | 2 +- .../sccm/client/updates/success/expected.json | 2 +- .../sccm_client_updates_fixture_contract.rs | 126 +++++++++++++++++- .../issue-323-client-updates-corpus.md | 13 +- 10 files changed, 147 insertions(+), 15 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md index ce7bb1f36..372602846 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/README.md @@ -47,8 +47,10 @@ 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. Time alone is never eligible, -topology is not evaluated here, and no server cause is claimed. +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, @@ -59,7 +61,8 @@ 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, and physical paths/fingerprints cannot alias another artifact. +coherent, physical paths/fingerprints cannot alias another artifact, and every +captured or capped artifact has a non-empty path fingerprint. ## Replay 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 index 23c33bca5..c8d45e110 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/capped/expected.json @@ -182,7 +182,7 @@ "siteCode": "LAB", "supHostHandle": "safe:sup:lab-sup-01", "keyConfidence": "exact", - "correlationEligible": true, + "correlationEligible": false, "timeOnlyEligible": false, "phase": "locateSup", "extractionProfileId": "updates-client-5.00.test-v1", 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 index baccf8ceb..2748c471f 100644 --- 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 @@ -175,7 +175,7 @@ "siteCode": "LAB", "supHostHandle": "safe:sup:lab-sup-01", "keyConfidence": "exact", - "correlationEligible": true, + "correlationEligible": false, "timeOnlyEligible": false, "phase": "locateSup", "extractionProfileId": "updates-client-5.00.test-v1", 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 index 0705037ad..aa5aff517 100644 --- 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 @@ -192,7 +192,7 @@ "siteCode": "LAB", "supHostHandle": "safe:sup:lab-sup-01", "keyConfidence": "exact", - "correlationEligible": true, + "correlationEligible": false, "timeOnlyEligible": false, "phase": "locateSup", "extractionProfileId": "updates-client-5.00.test-v1", 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 index fa9e70a51..e381274c0 100644 --- 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 @@ -205,7 +205,7 @@ "siteCode": "LAB", "supHostHandle": "safe:sup:lab-sup-01", "keyConfidence": "exact", - "correlationEligible": true, + "correlationEligible": false, "timeOnlyEligible": false, "phase": "locateSup", "extractionProfileId": "updates-client-5.00.test-v1", 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 index ac9488251..056b937be 100644 --- 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 @@ -200,7 +200,7 @@ "siteCode": "LAB", "supHostHandle": "safe:sup:lab-sup-01", "keyConfidence": "exact", - "correlationEligible": true, + "correlationEligible": false, "timeOnlyEligible": false, "phase": "locateSup", "extractionProfileId": "updates-client-5.00.test-v1", 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 index 69818c7a3..0046dc731 100644 --- 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 @@ -192,7 +192,7 @@ "siteCode": "LAB", "supHostHandle": "safe:sup:lab-sup-01", "keyConfidence": "exact", - "correlationEligible": true, + "correlationEligible": false, "timeOnlyEligible": false, "phase": "locateSup", "extractionProfileId": "updates-client-5.00.test-v1", 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 index 5c1e020c5..ce67a355d 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/expected.json @@ -221,7 +221,7 @@ "siteCode": "LAB", "supHostHandle": "safe:sup:lab-sup-01", "keyConfidence": "exact", - "correlationEligible": true, + "correlationEligible": false, "timeOnlyEligible": false, "phase": "locateSup", "extractionProfileId": "updates-client-5.00.test-v1", diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 0336cbdd6..b8c512682 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -599,7 +599,7 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> } for fact in facts { if fact["keyConfidence"] != "exact" - || fact["correlationEligible"] != true + || fact["correlationEligible"] != false || fact["timeOnlyEligible"] != false || fact["extractionProfileId"] != "updates-client-5.00.test-v1" || fact["siteCode"] != "LAB" @@ -1697,13 +1697,24 @@ fn counterpart_source_failures( ]; for fact in facts { + if expected["correlationHandoff"]["topologyCompatibilityEvaluated"] == false + && (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"] != true + || fact["correlationEligible"] != false || fact["timeOnlyEligible"] != false || fact["extractionProfileId"] != expected["extractionProfile"]["profileId"] || fact["phase"] != "locateSup" @@ -1833,6 +1844,15 @@ fn manifest_artifact_failures(scenario_dir: &Path, artifact: &Value) -> Vec Date: Thu, 30 Jul 2026 20:28:04 -0400 Subject: [PATCH 032/422] fix(sccm): fail closed on missing topology state --- .../sccm_client_updates_fixture_contract.rs | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index b8c512682..8ff90c263 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -1697,7 +1697,7 @@ fn counterpart_source_failures( ]; for fact in facts { - if expected["correlationHandoff"]["topologyCompatibilityEvaluated"] == false + if expected["correlationHandoff"]["topologyCompatibilityEvaluated"].as_bool() != Some(true) && (fact .as_object() .is_some_and(|fact| fact.contains_key("topologyCompatible")) @@ -2948,6 +2948,29 @@ fn software_update_fixture_rejects_unevaluated_topology_claims_and_eligibility() } } + 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() From 37dc6d08920c9dcb04a325f5dfe7531531695d9a Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 20:29:14 -0400 Subject: [PATCH 033/422] test(sccm): isolate rotation path provenance Refs #324 --- .../rotation-boundary/expected.json | 5 +- .../rotation-boundary/manifest.json | 2 +- ...m_client_task_sequence_fixture_contract.rs | 410 ++++++++++++++++-- .../issue-324-client-task-sequence-corpus.md | 31 +- 4 files changed, 406 insertions(+), 42 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/expected.json index fffff9bc8..f3ff470d0 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/expected.json @@ -8,9 +8,12 @@ "reorderedInputDeterministic": true, "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"partial","pathClasses":["client"],"artifactIds":["task-sequence-rotation-boundary-current","task-sequence-rotation-boundary-lo"]}], "artifactProvenance": [ - {"artifactId":"task-sequence-rotation-boundary-current","bytesCopied":141,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smsts.log","rotationKind":"current","fragmentComplete":false,"relocationOrdinal":0}, + {"artifactId":"task-sequence-rotation-boundary-current","bytesCopied":141,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":null,"rotationKind":"current","fragmentComplete":false,"relocationOrdinal":0}, {"artifactId":"task-sequence-rotation-boundary-lo","bytesCopied":259,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smsts.log","rotationKind":"lo","fragmentComplete":false,"relocationOrdinal":0} ], + "logicalReconstructions": [ + {"reconstructionId":"rotation-boundary-lo-current","logicalArtifactId":"client-task-sequence-smsts","orderedArtifactIds":["task-sequence-rotation-boundary-lo","task-sequence-rotation-boundary-current"],"pathFingerprint":"synthetic:rotation-boundary:client","pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":{"artifactId":"task-sequence-rotation-boundary-lo","startLine":1,"endLine":1},"coverageState":"partial","confidence":"low","correlationEligible":false} + ], "transactions": [], "sourceLocalObservations": [ {"observationId":"rotation-boundary-current-fragment","artifactId":"task-sequence-rotation-boundary-current","keyConfidence":"none","confidence":"low","confidenceCeiling":"low","correlationEligible":false,"evidence":{"artifactId":"task-sequence-rotation-boundary-current","startLine":1,"endLine":1},"reason":"A physical suffix fragment is not independently a complete CCM record."}, diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/manifest.json index a62ef0732..57f8102e7 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/manifest.json @@ -5,7 +5,7 @@ "scenario": "rotation-boundary", "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, "artifacts": [ - {"artifactId":"task-sequence-rotation-boundary-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:rotation-boundary:client","pathClass":"client","rotation":{"kind":"current","fragmentComplete":false},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:06Z","bytesCopied":141,"relativePath":"evidence/client-task-sequence-smsts/client/current/smsts.log"}, + {"artifactId":"task-sequence-rotation-boundary-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":null,"pathFingerprint":"synthetic:rotation-boundary:client","pathClass":"client","rotation":{"kind":"current","fragmentComplete":false},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:06Z","bytesCopied":141,"relativePath":"evidence/client-task-sequence-smsts/client/current/smsts.log"}, {"artifactId":"task-sequence-rotation-boundary-lo","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.lo_","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smsts.log","pathFingerprint":"synthetic:rotation-boundary:client","pathClass":"client","rotation":{"kind":"lo","fragmentComplete":false},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:05Z","bytesCopied":259,"relativePath":"evidence/client-task-sequence-smsts/client/lo/smsts.lo_"} ] } 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 index 751a66999..8aaa63a7e 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -83,6 +83,42 @@ fn read_json(path: &Path) -> Value { .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") @@ -503,8 +539,6 @@ fn validate_manifest_and_storage( let mut referenced_files = BTreeSet::new(); let mut logical_states = BTreeMap::>::new(); let mut logical_paths = BTreeMap::>::new(); - let mut observed_paths_by_fingerprint = BTreeMap::>::new(); - let mut captured_path_claims = Vec::<(String, String, String, String)>::new(); for artifact in artifacts { let artifact_id = artifact["artifactId"] @@ -644,11 +678,14 @@ fn validate_manifest_and_storage( let sanitized_path = artifact["sanitizedSourcePath"] .as_str() .ok_or_else(|| format!("{scenario}/{artifact_id}: no sanitized source path"))?; - if !sanitized_path.starts_with("SYNTHETIC://") - || artifact["smstsLogPathEvidence"] != sanitized_path - { + if !sanitized_path.starts_with("SYNTHETIC://") { return Err(format!( - "{scenario}/{artifact_id}: _SMSTSLogPath provenance is not bound" + "{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" )); } let contents = std::fs::read_to_string(&fixture_path) @@ -668,17 +705,37 @@ fn validate_manifest_and_storage( "{scenario}/{artifact_id}: fragmentComplete is not bound to physical CCM grammar" )); } - let fingerprint = path_fingerprint.to_owned(); - observed_paths_by_fingerprint - .entry(fingerprint.clone()) - .or_default() - .extend(smsts_log_paths(&contents)); - captured_path_claims.push(( - artifact_id.to_owned(), - fingerprint, - sanitized_path.to_owned(), - path_class.to_owned(), - )); + 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" + ) + })?, + ) + }; + match declared_path { + Some(declared_path) + if declared_path == sanitized_path + && observed_paths.len() == 1 + && observed_paths.contains(declared_path) => {} + Some(_) => { + return Err(format!( + "{scenario}/{artifact_id}: _SMSTSLogPath is not observed in this physical artifact" + )); + } + None if !fragment_complete && 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() @@ -696,22 +753,6 @@ fn validate_manifest_and_storage( } } - for (artifact_id, fingerprint, sanitized_path, path_class) in captured_path_claims { - let observed_paths = observed_paths_by_fingerprint - .get(&fingerprint) - .ok_or_else(|| format!("{scenario}/{artifact_id}: no _SMSTSLogPath evidence"))?; - if observed_paths.len() != 1 || !observed_paths.contains(&sanitized_path) { - return Err(format!( - "{scenario}/{artifact_id}: sanitized _SMSTSLogPath is not bound to physical evidence" - )); - } - if path_class_for_sanitized_path(&sanitized_path) != Some(path_class.as_str()) { - return Err(format!( - "{scenario}/{artifact_id}: pathClass is not bound to _SMSTSLogPath evidence" - )); - } - } - let actual_files = walk_files(&scenario_root.join("evidence")) .into_iter() .map(|path| { @@ -914,6 +955,184 @@ fn validate_contract( } } + 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 missing_physical_path_evidence = artifacts + .iter() + .filter(|artifact| { + artifact["captureState"] == "captured" && 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"] != 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 != missing_physical_path_evidence { + return Err(format!( + "{scenario}: every missing per-artifact _SMSTSLogPath must have one explicit logical reconstruction" + )); + } + let transactions = expected["transactions"] .as_array() .ok_or_else(|| "transactions are not an array".to_owned())?; @@ -2265,3 +2484,128 @@ fn coherent_review_mutations_fail_closed() { 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(", ") + ); +} diff --git a/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md b/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md index a4504f35d..d87d113dd 100644 --- a/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md +++ b/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md @@ -31,15 +31,19 @@ paths. Each captured artifact pins: - a physical artifact ID and safe repository-relative path; - the original basename and rotation kind; -- the sanitized source path; -- the `_SMSTSLogPath` value observed in the cited record; +- the sanitized capture source path; +- either the `_SMSTSLogPath` value observed in that physical artifact or an + explicit null for an incomplete fragment that contains no such token; - a path class and relocation ordinal; - the source version, capture timestamp, encoding, and exact byte count; and - whether that physical fragment is a complete logical CCM record. -`_SMSTSLogPath` is the authoritative path observation. A filename, display -name, timestamp, directory name, or assumed operating-system stage cannot -invent relocation or merge two artifacts. +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: @@ -138,8 +142,19 @@ 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. Until the -final intake interfaces define controlled logical reconstruction, both remain +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 @@ -210,6 +225,8 @@ Adversarial mutations prove the contract rejects: - 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. From 310cee519e68900e6a77dc7025d86b383154dc08 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 20:45:09 -0400 Subject: [PATCH 034/422] fix(sccm): canonicalize finding contract boundaries --- .../cmtraceopen-parser/src/sccm/findings.rs | 167 +++++- crates/cmtraceopen-parser/src/sccm/models.rs | 35 +- .../tests/sccm_spine_contract.rs | 547 ++++++++++++++++++ 3 files changed, 720 insertions(+), 29 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 8a7640925..a99feace5 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -1,4 +1,5 @@ use std::cmp::Ordering; +use std::collections::BTreeMap; use serde::de::Error as _; use serde::ser::Error as _; @@ -192,23 +193,25 @@ impl Serialize for SccmFinding { where S: Serializer, { - self.validate().map_err(|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: &self.finding_id, - class: &self.class, - phase: &self.phase, - role: &self.role, - severity: &self.severity, - confidence: &self.confidence, - 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, + 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) } @@ -367,6 +370,7 @@ impl<'de> Deserialize<'de> for SccmFinding { impl SccmFinding { pub fn validate(&self) -> Result<(), SccmFindingValidationError> { validate_required_text(self)?; + validate_roles(self)?; validate_all_evidence_references(self)?; validate_coverage_gaps(&self.coverage_gaps)?; validate_artifact_requests(&self.next_artifacts)?; @@ -413,7 +417,9 @@ impl SccmFinding { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SccmFindingValidationError { MissingRequiredField, + InvalidRole, InvalidEvidenceReference, + ConflictingEvidenceReference, MissingEvidenceOrCoverageGap, MissingTerminalEvidence, InvalidTerminalEvidence, @@ -585,9 +591,26 @@ fn validate_required_text(finding: &SccmFinding) -> Result<(), SccmFindingValida 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(()) +} + fn validate_all_evidence_references( finding: &SccmFinding, ) -> Result<(), SccmFindingValidationError> { + let mut ranges_by_identity = BTreeMap::new(); for reference in finding .evidence .iter() @@ -605,6 +628,14 @@ fn validate_all_evidence_references( ) { validate_evidence_reference(reference)?; + let identity = (reference.artifact_id.trim(), reference.entry_id.trim()); + let range = (reference.line_start, reference.line_end); + if ranges_by_identity + .insert(identity, range) + .is_some_and(|existing| existing != range) + { + return Err(SccmFindingValidationError::ConflictingEvidenceReference); + } } Ok(()) } @@ -676,19 +707,82 @@ fn is_bounded_request_reason(reason: &str) -> bool { return false; } - let lowercase = trimmed.to_ascii_lowercase(); - ![ - "entire drive", - "whole drive", - "drive root", - "root drive", - "entire disk", - "whole disk", - "all files", - "recursive", - ] - .iter() - .any(|unbounded| lowercase.contains(unbounded)) + !has_unbounded_request_scope(&trimmed.to_ascii_lowercase()) +} + +fn has_unbounded_request_scope(reason: &str) -> bool { + let tokens = reason + .split(|character: char| !character.is_ascii_alphanumeric()) + .filter(|token| !token.is_empty()) + .collect::>(); + let has_token = |candidates: &[&str]| tokens.iter().any(|token| candidates.contains(token)); + let is_wide_scope_token = |token: &str| { + matches!( + token, + "drivewide" | "diskwide" | "volumewide" | "filesystemwide" | "sitewide" | "systemwide" + ) + }; + let is_scope_token = |token: &str| { + matches!( + token, + "drive" | "disk" | "volume" | "filesystem" | "site" | "system" + ) + }; + let has_wide_scope = tokens.iter().any(|token| is_wide_scope_token(token)) + || tokens + .windows(2) + .any(|pair| is_scope_token(pair[0]) && pair[1] == "wide"); + + if has_wide_scope + || tokens.iter().any(|token| token.starts_with("recurs")) + || has_token(&["glob", "globs", "globbing"]) + { + return true; + } + + let has_broad_quantifier = has_token(&["all", "every", "entire", "whole", "full", "complete"]); + let has_filesystem_target = has_token(&[ + "file", + "files", + "directory", + "directories", + "folder", + "folders", + "drive", + "drives", + "disk", + "disks", + "volume", + "volumes", + "filesystem", + "filesystems", + ]); + if has_broad_quantifier && has_filesystem_target { + return true; + } + + let has_root_target = has_token(&["root"]) + && has_token(&[ + "directory", + "directories", + "folder", + "folders", + "drive", + "drives", + "disk", + "disks", + "volume", + "volumes", + "filesystem", + "filesystems", + "path", + "paths", + ]); + if has_root_target { + return true; + } + + false } fn validate_terminal_evidence( @@ -783,6 +877,20 @@ fn normalize_finding(finding: &mut SccmFinding) { finding.finding_id = finding.finding_id.trim().to_owned(); finding.title = finding.title.trim().to_owned(); finding.summary = finding.summary.trim().to_owned(); + if let SccmPhase::Unknown(value) = &mut finding.phase { + *value = value.trim().to_owned(); + } + for reference in &mut finding.evidence { + normalize_evidence_reference(reference); + } + for terminal in &mut finding.terminal_evidence { + normalize_evidence_reference(&mut terminal.reference); + } + for key in &mut finding.correlation_keys { + if let Some(reference) = &mut key.evidence { + normalize_evidence_reference(reference); + } + } for gap in &mut finding.coverage_gaps { gap.artifact_id = gap.artifact_id.trim().to_owned(); } @@ -807,6 +915,11 @@ fn normalize_finding(finding: &mut SccmFinding) { finding.next_artifacts.dedup(); } +fn normalize_evidence_reference(reference: &mut SccmEvidenceRef) { + reference.artifact_id = reference.artifact_id.trim().to_owned(); + reference.entry_id = reference.entry_id.trim().to_owned(); +} + fn compare_evidence_refs(left: &SccmEvidenceRef, right: &SccmEvidenceRef) -> Ordering { left.artifact_id .cmp(&right.artifact_id) diff --git a/crates/cmtraceopen-parser/src/sccm/models.rs b/crates/cmtraceopen-parser/src/sccm/models.rs index 7ac19c6a8..a87ba7802 100644 --- a/crates/cmtraceopen-parser/src/sccm/models.rs +++ b/crates/cmtraceopen-parser/src/sccm/models.rs @@ -1,3 +1,4 @@ +use serde::de::Error as _; use serde::ser::{Error as _, SerializeStruct}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Value; @@ -6,6 +7,8 @@ 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, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -46,6 +49,27 @@ impl SccmRole { 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 { @@ -53,6 +77,9 @@ impl Serialize for SccmRole { where S: Serializer, { + if !self.has_canonical_serialized_form() { + return Err(S::Error::custom(INVALID_SCCM_ROLE_MESSAGE)); + } serializer.serialize_str(self.serialized_name()) } } @@ -62,7 +89,7 @@ impl<'de> Deserialize<'de> for SccmRole { where D: Deserializer<'de>, { - Ok(match String::deserialize(deserializer)? { + let role = match String::deserialize(deserializer)? { value if value == "client" => Self::Client, value if value == "siteServer" => Self::SiteServer, value if value == "managementPoint" => Self::ManagementPoint, @@ -72,7 +99,11 @@ impl<'de> Deserialize<'de> for SccmRole { 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) } } diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index e1bbf2230..f433ce495 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -96,6 +96,27 @@ fn finding_request(logical_id: &str, role: SccmRole, reason: &str) -> SccmArtifa } } +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() +} + #[test] fn finding_confirmed_failure_requires_terminal_evidence() { let result = SccmFindingBuilder::new("app-enforcement-failed") @@ -472,6 +493,532 @@ fn finding_evidence_reference_allows_an_unavailable_line_range() { 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::ConflictingEvidenceReference + ); +} + +#[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}"); +} + +#[test] +fn finding_canonicalizes_evidence_identity_whitespace() { + let top_level = SccmEvidenceRef { + artifact_id: " artifact-a ".into(), + entry_id: " entry-a ".into(), + line_start: Some(1), + line_end: Some(1), + }; + let terminal = SccmEvidenceRef { + artifact_id: "artifact-a ".into(), + entry_id: "entry-a".into(), + ..top_level.clone() + }; + let key = SccmEvidenceRef { + artifact_id: " artifact-a".into(), + entry_id: " entry-a".into(), + ..top_level.clone() + }; + let finding = SccmFindingBuilder::new("canonical-evidence-identity") + .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() + .unwrap(); + + assert_eq!(finding.evidence[0].artifact_id, "artifact-a"); + assert_eq!(finding.evidence[0].entry_id, "entry-a"); + assert_eq!(finding.terminal_evidence[0].reference, finding.evidence[0]); + assert_eq!( + finding.correlation_keys[0].evidence.as_ref(), + Some(&finding.evidence[0]) + ); +} + +#[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!(result.is_err()); +} + +#[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_canonicalizes_future_phase_whitespace() { + let finding = SccmFindingBuilder::new("canonical-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(); + + assert_eq!(finding.phase, SccmPhase::Unknown("futurePhase".into())); + assert_eq!( + serde_json::to_value(finding).unwrap()["phase"], + "futurePhase" + ); +} + +#[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.", + ]; + + for reason in 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(); + + assert!(result.is_err(), "{reason}"); + } +} + +#[test] +fn finding_artifact_requests_accept_specific_bounded_reasons() { + for reason in [ + "Confirm the bounded policy request outcome.", + "Collect the PolicyAgent record cited by assignment A.", + "Confirm the root cause recorded by PolicyAgent.", + "Confirm the disk status code recorded in PolicyAgent.log.", + "Collect the disk imaging Task Sequence log.", + ] { + 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("policyAgent", SccmRole::Client, reason)) + .build() + .unwrap(); + } +} + +#[test] +fn finding_artifact_request_bounds_apply_to_deserialization_and_serialization() { + let finding = finding_with_gap_and_request("request-boundary-parity"); + let mut json = serde_json::to_value(&finding).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!("Collect every file on the system."); + let deserialize_error = serde_json::from_value::(json) + .unwrap_err() + .to_string(); + assert!( + deserialize_error.contains("InvalidArtifactRequestReason"), + "{deserialize_error}" + ); + + let mut mutated = finding; + mutated.next_artifacts[0].reason = "Scan the full disk for related evidence.".into(); + let serialize_error = serde_json::to_string(&mutated).unwrap_err().to_string(); + assert!( + serialize_error.contains("InvalidArtifactRequestReason"), + "{serialize_error}" + ); +} + #[test] fn finding_rejects_a_correlation_key_without_an_evidence_ref() { let cited = finding_evidence_ref("client-policy-agent", "policy:1-1"); From 670fe2d5bf84f2aae3b8dd78a5895a74501e3b71 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 20:52:01 -0400 Subject: [PATCH 035/422] fix(sccm): reject rooted artifact request paths --- .../cmtraceopen-parser/src/sccm/findings.rs | 13 +++++ .../tests/sccm_spine_contract.rs | 57 +++++++++++++------ 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index a99feace5..430309e92 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -702,6 +702,7 @@ fn is_bounded_request_reason(reason: &str) -> bool { let trimmed = reason.trim(); if trimmed.is_empty() || trimmed.chars().count() > MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS + || contains_rooted_path(trimmed) || trimmed.contains(['*', '?', '[', ']']) { return false; @@ -710,6 +711,18 @@ fn is_bounded_request_reason(reason: &str) -> bool { !has_unbounded_request_scope(&trimmed.to_ascii_lowercase()) } +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'.' + )) + }) +} + fn has_unbounded_request_scope(reason: &str) -> bool { let tokens = reason .split(|character: char| !character.is_ascii_alphanumeric()) diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index f433ce495..2c031839c 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -117,6 +117,16 @@ fn finding_with_gap_and_request(finding_id: &str) -> SccmFinding { .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.", +]; + #[test] fn finding_confirmed_failure_requires_terminal_evidence() { let result = SccmFindingBuilder::new("app-enforcement-failed") @@ -960,7 +970,7 @@ fn finding_artifact_requests_reject_structurally_unbounded_reasons() { r"Collect C:\Windows\CCM\Logs\*.log.", ]; - for reason in reasons { + for reason in reasons.into_iter().chain(ROOTED_ARTIFACT_REQUEST_REASONS) { let result = SccmFindingBuilder::new("unbounded-structural-request") .class(SccmFindingClass::Symptom) .phase(SccmPhase::Policy) @@ -971,7 +981,11 @@ fn finding_artifact_requests_reject_structurally_unbounded_reasons() { .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) .build(); - assert!(result.is_err(), "{reason}"); + assert_eq!( + result.unwrap_err(), + SccmFindingValidationError::InvalidArtifactRequestReason, + "{reason}" + ); } } @@ -983,6 +997,8 @@ fn finding_artifact_requests_accept_specific_bounded_reasons() { "Confirm the root cause recorded by PolicyAgent.", "Confirm the disk status code recorded in PolicyAgent.log.", "Collect the disk imaging Task Sequence log.", + "Collect Logs/PolicyAgent.log from the bounded bundle.", + r"Collect Logs\PolicyAgent.log from the bounded bundle.", ] { SccmFindingBuilder::new("bounded-structural-request") .class(SccmFindingClass::Symptom) @@ -1000,23 +1016,28 @@ fn finding_artifact_requests_accept_specific_bounded_reasons() { #[test] fn finding_artifact_request_bounds_apply_to_deserialization_and_serialization() { let finding = finding_with_gap_and_request("request-boundary-parity"); - let mut json = serde_json::to_value(&finding).unwrap(); - json["nextArtifacts"][0]["reason"] = serde_json::json!("Collect every file on the system."); - let deserialize_error = serde_json::from_value::(json) - .unwrap_err() - .to_string(); - assert!( - deserialize_error.contains("InvalidArtifactRequestReason"), - "{deserialize_error}" - ); + for reason in ROOTED_ARTIFACT_REQUEST_REASONS.into_iter().chain([ + "Collect every file on the system.", + "Scan the full disk for related evidence.", + ]) { + let mut json = serde_json::to_value(&finding).unwrap(); + json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + let deserialize_error = serde_json::from_value::(json) + .unwrap_err() + .to_string(); + assert!( + deserialize_error.contains("InvalidArtifactRequestReason"), + "{reason}: {deserialize_error}" + ); - let mut mutated = finding; - mutated.next_artifacts[0].reason = "Scan the full disk for related evidence.".into(); - let serialize_error = serde_json::to_string(&mutated).unwrap_err().to_string(); - assert!( - serialize_error.contains("InvalidArtifactRequestReason"), - "{serialize_error}" - ); + let mut mutated = finding.clone(); + mutated.next_artifacts[0].reason = reason.into(); + let serialize_error = serde_json::to_string(&mutated).unwrap_err().to_string(); + assert!( + serialize_error.contains("InvalidArtifactRequestReason"), + "{reason}: {serialize_error}" + ); + } } #[test] From b83176b9c4fb0c14e0f027f0fa669c50e0d7bfb1 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 20:59:29 -0400 Subject: [PATCH 036/422] test(sccm): prepare inventory compliance metering corpus --- .../inventory-compliance-metering/README.md | 32 + .../root-a/current/CIAgent.log | 1 + .../root-a/current/CITaskMgr.log | 1 + .../root-a/current/DCMAgent.log | 1 + .../compliance/coverage-states/expected.json | 73 + .../compliance/coverage-states/manifest.json | 195 +++ .../root-a/current/CIAgent.log | 1 + .../root-a/current/CITaskMgr.log | 1 + .../root-a/current/DCMReporting.log | 1 + .../expected.json | 67 + .../manifest.json | 102 ++ .../root-a/current/CIAgent.log | 1 + .../noncompliant-result/expected.json | 54 + .../noncompliant-result/manifest.json | 46 + .../root-a/current/DCMReporting.log | 4 + .../recovery-contradictory/expected.json | 91 ++ .../recovery-contradictory/manifest.json | 46 + .../root-a/current/CIAgent.log | 1 + .../root-a/current/DCMAgent.log | 1 + .../root-a/current/DCMReporting.log | 1 + .../remediation-success/expected.json | 64 + .../remediation-success/manifest.json | 102 ++ .../root-a/current/CIAgent.log | 1 + .../root-b/current/CIAgent.log | 1 + .../same-minute-collision/expected.json | 86 ++ .../same-minute-collision/manifest.json | 74 + .../root-a/current/CIAgent.log | 1 + .../root-a/current/DCMReporting.log | 1 + .../compliance/success/expected.json | 59 + .../compliance/success/manifest.json | 74 + .../root-a/current/CIAgent.log | 1 + .../root-a/current/DCMAgent.log | 1 + .../root-a/current/DCMReporting.log | 1 + .../terminal-failures/expected.json | 130 ++ .../terminal-failures/manifest.json | 102 ++ .../root-a/current/InventoryAgent.log | 1 + .../root-c/current/InventoryAgentProvider.log | 1 + .../root-f/current/InventoryAgentProvider.log | 1 + .../inventory/coverage-states/expected.json | 73 + .../inventory/coverage-states/manifest.json | 195 +++ .../root-a/current/InventoryAgentProvider.log | 4 + .../recovery-contradictory/expected.json | 89 ++ .../recovery-contradictory/manifest.json | 46 + .../root-a/current/InventoryAgentProvider.log | 1 + .../root-a/lo/InventoryAgent.log.lo | 1 + .../inventory/rotation-boundary/expected.json | 43 + .../inventory/rotation-boundary/manifest.json | 74 + .../root-a/current/InventoryAgentProvider.log | 1 + .../root-b/current/InventoryAgentProvider.log | 1 + .../same-minute-collision/expected.json | 84 + .../same-minute-collision/manifest.json | 74 + .../root-a/current/InventoryAgent.log | 1 + .../root-a/current/InventoryAgentProvider.log | 2 + .../root-a/current/InventoryProvider.log | 2 + .../inventory/success/expected.json | 63 + .../inventory/success/manifest.json | 102 ++ .../root-a/current/InventoryAgent.log | 1 + .../root-a/current/InventoryAgentProvider.log | 2 + .../root-a/current/InventoryProvider.log | 2 + .../inventory/terminal-failures/expected.json | 187 +++ .../inventory/terminal-failures/manifest.json | 102 ++ .../root-c/current/SWMTRReportGen.log | 1 + .../root-f/current/SWMTRReportGen.log | 1 + .../root-g/current/SWMTRReportGen.log | 1 + .../metering/coverage-states/expected.json | 73 + .../metering/coverage-states/manifest.json | 195 +++ .../root-a/current/SWMTRReportGen.log | 4 + .../recovery-contradictory/expected.json | 91 ++ .../recovery-contradictory/manifest.json | 46 + .../root-a/current/SWMTRReportGen.log | 1 + .../root-a/lo/SWMTRReportGen.log.lo | 1 + .../metering/rotation-boundary/expected.json | 43 + .../metering/rotation-boundary/manifest.json | 74 + .../root-a/current/SWMTRReportGen.log | 1 + .../root-b/current/SWMTRReportGen.log | 1 + .../same-minute-collision/expected.json | 86 ++ .../same-minute-collision/manifest.json | 74 + .../root-a/current/SWMTRReportGen.log | 3 + .../metering/success/expected.json | 54 + .../metering/success/manifest.json | 46 + .../root-a/current/SWMTRReportGen.log | 3 + .../metering/terminal-failures/expected.json | 120 ++ .../metering/terminal-failures/manifest.json | 46 + ...ry_compliance_metering_fixture_contract.rs | 1357 +++++++++++++++++ ...nt-inventory-compliance-metering-corpus.md | 148 ++ 85 files changed, 5041 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/README.md create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/evidence/client-compliance/root-a/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/evidence/client-compliance/root-a/current/CITaskMgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/evidence/client-compliance/root-a/current/DCMAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/evidence/client-compliance/root-a/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/evidence/client-compliance/root-a/current/CITaskMgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/evidence/client-compliance/root-a/current/DCMReporting.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/noncompliant-result/evidence/client-compliance/root-a/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/noncompliant-result/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/noncompliant-result/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/recovery-contradictory/evidence/client-compliance/root-a/current/DCMReporting.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/recovery-contradictory/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/recovery-contradictory/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/evidence/client-compliance/root-a/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/evidence/client-compliance/root-a/current/DCMAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/evidence/client-compliance/root-a/current/DCMReporting.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/evidence/client-compliance/root-a/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/evidence/client-compliance/root-b/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/evidence/client-compliance/root-a/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/evidence/client-compliance/root-a/current/DCMReporting.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/evidence/client-compliance/root-a/current/CIAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/evidence/client-compliance/root-a/current/DCMAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/evidence/client-compliance/root-a/current/DCMReporting.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/evidence/client-inventory/root-a/current/InventoryAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/evidence/client-inventory/root-c/current/InventoryAgentProvider.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/evidence/client-inventory/root-f/current/InventoryAgentProvider.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/recovery-contradictory/evidence/client-inventory/root-a/current/InventoryAgentProvider.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/recovery-contradictory/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/recovery-contradictory/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/evidence/client-inventory/root-a/current/InventoryAgentProvider.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/evidence/client-inventory/root-a/lo/InventoryAgent.log.lo create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/evidence/client-inventory/root-a/current/InventoryAgentProvider.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/evidence/client-inventory/root-b/current/InventoryAgentProvider.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/evidence/client-inventory/root-a/current/InventoryAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/evidence/client-inventory/root-a/current/InventoryAgentProvider.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/evidence/client-inventory/root-a/current/InventoryProvider.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/evidence/client-inventory/root-a/current/InventoryAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/evidence/client-inventory/root-a/current/InventoryAgentProvider.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/evidence/client-inventory/root-a/current/InventoryProvider.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/evidence/client-metering/root-c/current/SWMTRReportGen.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/evidence/client-metering/root-f/current/SWMTRReportGen.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/evidence/client-metering/root-g/current/SWMTRReportGen.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/recovery-contradictory/evidence/client-metering/root-a/current/SWMTRReportGen.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/recovery-contradictory/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/recovery-contradictory/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/evidence/client-metering/root-a/current/SWMTRReportGen.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/evidence/client-metering/root-a/lo/SWMTRReportGen.log.lo create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/evidence/client-metering/root-a/current/SWMTRReportGen.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/evidence/client-metering/root-b/current/SWMTRReportGen.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/success/evidence/client-metering/root-a/current/SWMTRReportGen.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/success/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/success/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/terminal-failures/evidence/client-metering/root-a/current/SWMTRReportGen.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/terminal-failures/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/terminal-failures/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_inventory_compliance_metering_fixture_contract.rs create mode 100644 docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md 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..4a6e36c56 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/README.md @@ -0,0 +1,32 @@ +# SCCM client inventory, compliance, and metering preparation corpus + +This issue-#325 corpus is synthetic, sanitized, deterministic, and +`proposedPending318And319`. It is test preparation only: no fixture claims live +Windows acceptance or production source-profile support. + +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; +- optional `evidence/`: raw CCM transport records or deliberately incomplete + synthetic input. + +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 +``` 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..58eaed1bc --- /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 @@ + 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..4e14581e5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/malformed-unknown-profile-invalid-offset/expected.json @@ -0,0 +1,67 @@ +{ + "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-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." + }, + { + "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." + } + ], + "coverage": [ + { + "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" + }, + { + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-invalid-offset", + "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..5bad3f985 --- /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-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-30T01: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-30T01:00:00Z", + "bytesCopied": 329, + "relativePath": "evidence/client-compliance/root-a/current/CITaskMgr.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "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-30T01:00:00Z", + "bytesCopied": 306, + "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/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..a10cd741d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/noncompliant-result/expected.json @@ -0,0 +1,54 @@ +{ + "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..df22fe7bd --- /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-30T01: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..d8020d8b3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/recovery-contradictory/expected.json @@ -0,0 +1,91 @@ +{ + "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..1ec9ae61a --- /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-30T01: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..59970693a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/remediation-success/expected.json @@ -0,0 +1,64 @@ +{ + "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..7a0bad1f0 --- /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-30T01: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-30T01: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-30T01: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..c72266717 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/same-minute-collision/expected.json @@ -0,0 +1,86 @@ +{ + "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..5e249f311 --- /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-30T01: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-30T01: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..27f7d654f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/success/expected.json @@ -0,0 +1,59 @@ +{ + "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..0690569a8 --- /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-30T01: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-30T01: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..3b14ff5bf --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/terminal-failures/expected.json @@ -0,0 +1,130 @@ +{ + "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": "Evaluate", + "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": "Remediate", + "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..3b768c9ce --- /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-30T01: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-30T01: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-30T01: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..3e93c629b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/recovery-contradictory/expected.json @@ -0,0 +1,89 @@ +{ + "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..57e5fe873 --- /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-30T01: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/InventoryAgentProvider.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/evidence/client-inventory/root-a/current/InventoryAgentProvider.log new file mode 100644 index 000000000..5157b3527 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/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/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..176473e5e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/expected.json @@ -0,0 +1,43 @@ +{ + "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-lo", + "inventory-rotation-boundary-report-current" + ], + "confidenceCeiling": "low", + "correlationEligible": false, + "claim": "Exact keys split only across incomplete rotation fragments cannot establish a complete workflow." + } + ], + "coverage": [ + { + "artifactId": "inventory-rotation-boundary-agent-lo", + "logicalArtifactId": "client-inventory", + "state": "partial" + }, + { + "artifactId": "inventory-rotation-boundary-report-current", + "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..0118c53f9 --- /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-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-30T01:00:00Z", + "bytesCopied": 308, + "relativePath": "evidence/client-inventory/root-a/lo/InventoryAgent.log.lo", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "artifactId": "inventory-rotation-boundary-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-rotation-boundary-report-current-root-a", + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "sourceVersion": "5.00.TEST.325", + "capturedUtc": "2026-07-30T01:00:00Z", + "bytesCopied": 314, + "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/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..f67299649 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/same-minute-collision/expected.json @@ -0,0 +1,84 @@ +{ + "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..6701c7ff8 --- /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-30T01: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-30T01: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..219f35827 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/success/expected.json @@ -0,0 +1,63 @@ +{ + "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..d5786a3fb --- /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-30T01: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-30T01: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-30T01: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..fd3a474a6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/terminal-failures/expected.json @@ -0,0 +1,187 @@ +{ + "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": "Collect", + "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": "Provider", + "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": "Serialize", + "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": "Queue", + "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..9a617601b --- /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-30T01: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-30T01: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-30T01: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/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..7e83e25eb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/recovery-contradictory/expected.json @@ -0,0 +1,91 @@ +{ + "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..9ba35c068 --- /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-30T01: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..e9b382aee --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/rotation-boundary/expected.json @@ -0,0 +1,43 @@ +{ + "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-lo", + "metering-rotation-boundary-report-current" + ], + "confidenceCeiling": "low", + "correlationEligible": false, + "claim": "Exact keys split only across incomplete rotation fragments cannot establish a complete workflow." + } + ], + "coverage": [ + { + "artifactId": "metering-rotation-boundary-report-lo", + "logicalArtifactId": "client-metering", + "state": "partial" + }, + { + "artifactId": "metering-rotation-boundary-report-current", + "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..1a78a8af3 --- /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-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-30T01:00:00Z", + "bytesCopied": 327, + "relativePath": "evidence/client-metering/root-a/lo/SWMTRReportGen.log.lo", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } + }, + { + "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-30T01:00:00Z", + "bytesCopied": 323, + "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/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..575eee7a7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/same-minute-collision/expected.json @@ -0,0 +1,86 @@ +{ + "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..ea7d2647c --- /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-30T01: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-30T01: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..ee610f911 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/success/expected.json @@ -0,0 +1,54 @@ +{ + "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..d8f71fe0a --- /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-30T01: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..415ec4723 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/terminal-failures/expected.json @@ -0,0 +1,120 @@ +{ + "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": "Collect", + "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": "Aggregate", + "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..92273e8f1 --- /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-30T01: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/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..b5ad8c73b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_inventory_compliance_metering_fixture_contract.rs @@ -0,0 +1,1357 @@ +use cmtraceopen_parser::models::log_entry::LogFormat; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path, PathBuf}; + +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; 6] = [ + "coverage-states", + "recovery-contradictory", + "rotation-boundary", + "same-minute-collision", + "success", + "terminal-failures", +]; + +const DOCUMENTED_CORPUS_DIGEST: &str = "6eef3efbb0c531ba"; + +#[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: 20, + 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 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 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_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 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 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_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 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 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 (entries, errors) = + cmtraceopen_parser::parser::ccm::parse_content(line, artifact_id, None); + if errors != 0 + || entries.len() != 1 + || entries[0].format != LogFormat::Ccm + || entries[0].line_number != 1 + { + return Err(format!( + "{artifact_id}:{} is not one complete CCM record", + start + offset + )); + } + let offset_minutes = entries[0] + .timezone_offset + .ok_or_else(|| format!("{artifact_id}:{} has no source offset", start + offset))?; + let source_version = required_string(artifact, "sourceVersion", artifact_id)?; + records.push(( + (*line).to_owned(), + offset_minutes, + source_version.to_owned(), + )); + } + } + 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)?; + + 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")?; + if !bundle_id.starts_with(&format!("sccm-325-{family}-")) { + return Err("bundleId is not issue/family scoped".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 relative_paths = 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(); + + for artifact in artifacts { + let artifact_id = required_string(artifact, "artifactId", "artifact")?; + 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")); + } + 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 relative_path = artifact["relativePath"].as_str(); + if physical != relative_path.is_some() { + return Err(format!( + "{artifact_id} physical path does not match capture state {capture_state}" + )); + } + + 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")); + } + if capture_state == "capped" + && (artifact["collectionLimit"]["limitApplied"] != true + || artifact["truncated"] != true + || artifact["rotation"]["fragmentComplete"] != false) + { + return Err(format!( + "{artifact_id} capped state lacks cap/partial provenance" + )); + } + 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)?; + if !sanitized_path.starts_with("SYNTHETIC://") || !sanitized_path.ends_with(basename) { + return Err(format!("{artifact_id} source path is not sanitized")); + } + 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" + )); + } + referenced_files.insert(relative_path.to_owned()); + + if let Some(version) = artifact["sourceVersion"].as_str() { + if !version.starts_with("5.00.TEST.") { + unknown_version_artifacts.insert(artifact_id.to_owned()); + } + } else { + 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 (entries, _) = + cmtraceopen_parser::parser::ccm::parse_content(contents, artifact_id, None); + if entries.iter().any(|entry| { + entry + .timezone_offset + .is_some_and(|offset| offset.abs() > 1_439) + }) { + 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)?; + if !sanitized_path.starts_with("SYNTHETIC://") + || !sanitized_path.ends_with(basename) + { + return Err(format!( + "{artifact_id} attempted source path is unsanitized" + )); + } + 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" + )); + } + } + } + } + + 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 {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 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())?; + let mut declared_coverage = BTreeMap::new(); + for row in coverage { + 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 { + return Err("prohibitedClaims does not cover all four safety boundaries".to_owned()); + } + + let observations = expected["sourceLocalObservations"] + .as_array() + .ok_or_else(|| "sourceLocalObservations is not an array".to_owned())?; + let mut observation_ids = 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 { + let observation_id = required_string(observation, "observationId", "observation")?; + 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)?; + let lower_claim = claim.to_ascii_lowercase(); + if lower_claim.contains("server-side cause") || lower_claim.contains("proves") { + return Err(format!("{observation_id} makes a causal/proof claim")); + } + 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" + )); + } + 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 !artifacts_by_id.contains_key(artifact_id) { + return Err(format!( + "{observation_id} references unknown artifact {artifact_id}" + )); + } + observed_artifact_ids.insert(artifact_id.to_owned()); + if kind == "unknownProfile" { + unknown_profile_observations.insert(artifact_id.to_owned()); + } + if kind == "invalidOffset" { + invalid_offset_observations.insert(artifact_id.to_owned()); + } + } + } + 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(); + for transaction in transactions { + let transaction_id = required_string(transaction, "transactionId", "transaction")?; + 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 phase = required_string(transaction, "phase", transaction_id)?; + if !phases.contains(&phase) { + return Err(format!( + "{transaction_id} has invalid {family} phase {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" + )); + } + } else if !transaction["lastSuccessfulPhase"].is_null() { + return Err(format!( + "{transaction_id} lastSuccessfulPhase is neither string nor null" + )); + } + 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 records = evidence_record_texts(scenario_root, &artifacts_by_id, evidence_refs)?; + for (record, _, _) in &records { + for field in required_fields { + let value = key[*field].as_str().expect("validated key string"); + if !record.contains(&format!("{field}={value}")) { + return Err(format!( + "{transaction_id} {field} is not co-located in every cited CCM record" + )); + } + } + } + if !records + .iter() + .any(|(record, _, _)| record.contains(&format!("Phase={phase}"))) + { + return Err(format!( + "{transaction_id} phase {phase} is not bound to cited evidence" + )); + } + + let confidence = required_string(transaction, "confidence", transaction_id)?; + if confidence == "high" + && records.iter().any(|(_, offset, version)| { + offset.abs() > 1_439 || !version.starts_with("5.00.TEST.") + }) + { + return Err(format!( + "{transaction_id} high confidence lacks usable offset/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.contains(&format!("Phase={phase}")) + && record.contains(&format!("Disposition={disposition}")) + && record.contains(&format!("Terminal={terminal}")) + }) + }; + 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" + || !has_phase_record(disposition, true) + || !records + .iter() + .any(|(record, _, _)| record.contains("ResultType=Evaluation")) + { + return Err(format!( + "{transaction_id} compliance evaluation result contract is invalid" + )); + } + } + "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" + )); + } + } + "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" + )); + } + } + other => { + return Err(format!( + "{transaction_id} has unsupported preparation classification {other}" + )); + } + } + + let coverage_gap_ids = transaction["coverageGapArtifactIds"] + .as_array() + .ok_or_else(|| format!("{transaction_id} coverageGapArtifactIds is not an array"))?; + 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"))?; + 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"])?; + } + + 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" + ); +} + +#[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: 20, + artifacts: 54, + evidence_files: 42, + evidence_bytes: 16_479, + capture_states: BTreeMap::from([ + ("absent".to_owned(), 3), + ("accessDenied".to_owned(), 3), + ("capped".to_owned(), 3), + ("captured".to_owned(), 35), + ("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 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 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 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 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, + ); +} 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..7a3ca7498 --- /dev/null +++ b/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md @@ -0,0 +1,148 @@ +# 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 in one +complete cited CCM logical record. A field borrowed from another line, artifact, +root, rotation, or workflow cannot complete a key. 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` + +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,479 bytes). The deterministic fixture digest is `6eef3efbb0c531ba`. + +| 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 physical artifact identity 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. + +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 workflow and one versioned exact-key + profile; +- every evidence reference names a manifest artifact and valid line range; +- transaction citations contain complete raw CCM records; +- `findings` remains empty until production reducers are authorized; +- source-local observations have a low confidence ceiling and are not + correlation eligible; +- 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; +- cross-family key fields, uncited key values, and phase borrowing from another + record; +- high-confidence output from an unknown source profile or invalid timestamp + offset; +- promotion of missing coverage to captured evidence; +- promotion of noncompliance to confirmed failure; +- same-minute key borrowing between distinct root artifacts; +- merging same-minute inventory and compliance terminal failures; +- unbounded next-artifact requests. + +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. From 50f2dedd1aede2631ad3ceaaf16e5be9129f110a Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 21:07:20 -0400 Subject: [PATCH 037/422] test(sccm): harden issue 325 evidence guards --- .../root-a/current/DCMReporting.log | 3 +- .../manifest.json | 2 +- ...ry_compliance_metering_fixture_contract.rs | 125 ++++++++++++++++-- ...nt-inventory-compliance-metering-corpus.md | 4 +- 4 files changed, 123 insertions(+), 11 deletions(-) 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 index 58eaed1bc..3d6f401fe 100644 --- 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 @@ -1 +1,2 @@ - + + 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 index 5bad3f985..e16718cb8 100644 --- 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 @@ -90,7 +90,7 @@ }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T01:00:00Z", - "bytesCopied": 306, + "bytesCopied": 647, "relativePath": "evidence/client-compliance/root-a/current/DCMReporting.log", "encoding": "utf-8", "collectionLimit": { 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 index b5ad8c73b..905da5ebe 100644 --- 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 @@ -32,7 +32,7 @@ const METERING_SCENARIOS: [&str; 6] = [ "terminal-failures", ]; -const DOCUMENTED_CORPUS_DIGEST: &str = "6eef3efbb0c531ba"; +const DOCUMENTED_CORPUS_DIGEST: &str = "409f976350ffbc05"; #[derive(Debug, PartialEq, Eq)] struct CorpusInventory { @@ -403,6 +403,21 @@ fn evidence_record_texts( Ok(records) } +fn record_contains_exact_key_pair(record: &str, field: &str, value: &str) -> bool { + const MESSAGE_PREFIX: &str = ""; + let Some(message_start) = record.find(MESSAGE_PREFIX) else { + return false; + }; + let payload_start = message_start + MESSAGE_PREFIX.len(); + let Some(message_end) = record[payload_start..].find(MESSAGE_SUFFIX) else { + return false; + }; + record[payload_start..payload_start + message_end] + .split_ascii_whitespace() + .any(|token| token.split_once('=') == Some((field, value))) +} + fn validate_contract( family: &str, scenario: &str, @@ -838,7 +853,7 @@ fn validate_contract( for (record, _, _) in &records { for field in required_fields { let value = key[*field].as_str().expect("validated key string"); - if !record.contains(&format!("{field}={value}")) { + if !record_contains_exact_key_pair(record, field, value) { return Err(format!( "{transaction_id} {field} is not co-located in every cited CCM record" )); @@ -855,13 +870,12 @@ fn validate_contract( } let confidence = required_string(transaction, "confidence", transaction_id)?; - if confidence == "high" - && records.iter().any(|(_, offset, version)| { - offset.abs() > 1_439 || !version.starts_with("5.00.TEST.") - }) + if records + .iter() + .any(|(_, offset, version)| offset.abs() > 1_439 || !version.starts_with("5.00.TEST.")) { return Err(format!( - "{transaction_id} high confidence lacks usable offset/profile provenance" + "{transaction_id} exact-key transaction lacks usable offset/profile provenance" )); } let state = required_string(transaction, "state", transaction_id)?; @@ -1009,7 +1023,7 @@ fn corpus_inventory_is_deterministic_and_documented() { scenarios: 20, artifacts: 54, evidence_files: 42, - evidence_bytes: 16_479, + evidence_bytes: 16_820, capture_states: BTreeMap::from([ ("absent".to_owned(), 3), ("accessDenied".to_owned(), 3), @@ -1263,6 +1277,101 @@ fn dynamic_evidence_mutations_cannot_fabricate_exact_or_high_confidence_facts() ); } +#[test] +fn exact_key_tokens_reject_lookalike_field_names_and_values() { + let value = "INV-REPORT-001"; + assert!(record_contains_exact_key_pair( + "", + "ReportId", + value + )); + for lookalike in [ + "", + "", + "", + "", + ] { + assert!( + !record_contains_exact_key_pair(lookalike, "ReportId", value), + "look-alike key token was accepted: {lookalike}" + ); + } +} + +#[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 cannot support ordered recovery." + })); + 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 dynamic_coverage_and_collision_mutations_remain_noncausal() { let (coverage_root, coverage_manifest, mut coverage_expected) = 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 index 7a3ca7498..0dc8a3846 100644 --- a/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md +++ b/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md @@ -42,7 +42,7 @@ low-confidence observation and a coverage/profile gap. 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,479 bytes). The deterministic fixture digest is `6eef3efbb0c531ba`. +(16,820 bytes). The deterministic fixture digest is `409f976350ffbc05`. | Family | Scenarios | Contract coverage | | --- | --- | --- | @@ -116,8 +116,10 @@ of: aliasing; - 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; - high-confidence output from an unknown source profile or invalid timestamp offset; +- medium-confidence recovery from an unknown profile or unusable offset; - promotion of missing coverage to captured evidence; - promotion of noncompliance to confirmed failure; - same-minute key borrowing between distinct root artifacts; From 117cec9a02fe0f67ae5875e6e7e03bfadc23b3d6 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 21:14:10 -0400 Subject: [PATCH 038/422] fix(sccm): converge finding validation boundaries --- .../cmtraceopen-parser/src/sccm/findings.rs | 262 +++++---- .../tests/sccm_spine_contract.rs | 511 +++++++++++++++--- 2 files changed, 606 insertions(+), 167 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 430309e92..5ed1aeb32 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -46,6 +46,15 @@ impl SccmPhase { 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 { @@ -53,9 +62,9 @@ impl Serialize for SccmPhase { where S: Serializer, { - if matches!(self, Self::Unknown(value) if is_known_phase_name(value)) { + if !self.has_canonical_serialized_form() { return Err(S::Error::custom( - "unknown SCCM phase must not shadow a declared phase", + "unknown SCCM phase must be canonical and must not shadow a declared phase", )); } serializer.serialize_str(self.serialized_name()) @@ -67,12 +76,18 @@ impl<'de> Deserialize<'de> for SccmPhase { where D: Deserializer<'de>, { - Ok(match String::deserialize(deserializer)? { + 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) } } @@ -193,6 +208,9 @@ impl Serialize for SccmFinding { 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| { @@ -580,11 +598,10 @@ impl SccmFindingBuilder { } fn validate_required_text(finding: &SccmFinding) -> Result<(), SccmFindingValidationError> { - if finding.finding_id.trim().is_empty() + if !is_canonical_opaque_id(&finding.finding_id) || finding.title.trim().is_empty() || finding.summary.trim().is_empty() - || matches!(&finding.phase, SccmPhase::Unknown(value) if value.trim().is_empty()) - || matches!(&finding.phase, SccmPhase::Unknown(value) if is_known_phase_name(value)) + || !finding.phase.has_canonical_serialized_form() { return Err(SccmFindingValidationError::MissingRequiredField); } @@ -628,7 +645,7 @@ fn validate_all_evidence_references( ) { validate_evidence_reference(reference)?; - let identity = (reference.artifact_id.trim(), reference.entry_id.trim()); + let identity = (reference.artifact_id.as_str(), reference.entry_id.as_str()); let range = (reference.line_start, reference.line_end); if ranges_by_identity .insert(identity, range) @@ -648,8 +665,8 @@ fn validate_evidence_reference( (Some(start), Some(end)) => start > 0 && end >= start, _ => false, }; - if reference.artifact_id.trim().is_empty() - || reference.entry_id.trim().is_empty() + if !is_canonical_opaque_id(&reference.artifact_id) + || !is_canonical_opaque_id(&reference.entry_id) || !valid_line_range { return Err(SccmFindingValidationError::InvalidEvidenceReference); @@ -661,7 +678,7 @@ fn validate_coverage_gaps( coverage_gaps: &[SccmFindingCoverageGap], ) -> Result<(), SccmFindingValidationError> { if coverage_gaps.iter().any(|gap| { - gap.artifact_id.trim().is_empty() + !is_canonical_opaque_id(&gap.artifact_id) || gap.artifact_id.chars().count() > MAX_SCCM_COVERAGE_GAP_ARTIFACT_ID_CHARS || gap.coverage == SccmCoverageState::Captured }) { @@ -679,6 +696,9 @@ fn validate_artifact_requests( let catalog = declared_source_catalog(); for request in requests { + if !is_canonical_opaque_id(&request.logical_id) { + return Err(SccmFindingValidationError::UndeclaredArtifactRequest); + } if !is_bounded_request_reason(&request.reason) { return Err(SccmFindingValidationError::InvalidArtifactRequestReason); } @@ -728,73 +748,143 @@ fn has_unbounded_request_scope(reason: &str) -> bool { .split(|character: char| !character.is_ascii_alphanumeric()) .filter(|token| !token.is_empty()) .collect::>(); - let has_token = |candidates: &[&str]| tokens.iter().any(|token| candidates.contains(token)); - let is_wide_scope_token = |token: &str| { - matches!( - token, + + 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_broad_collection_scope(&tokens) +} + +fn is_collection_action(token: &str) -> bool { + matches!( + token, + "collect" + | "scan" + | "search" + | "walk" + | "traverse" + | "capture" + | "export" + | "gather" + | "inspect" + | "read" + ) +} + +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 has_nearby_collection_action_before(tokens: &[&str], index: usize) -> bool { + let start = index.saturating_sub(4); + tokens[start..index] + .iter() + .any(|token| is_collection_action(token)) +} + +fn has_recursive_collection_scope(tokens: &[&str]) -> bool { + tokens.iter().enumerate().any(|(index, token)| { + token.starts_with("recurs") + && (has_nearby_collection_action_before(tokens, index) + || tokens + .iter() + .skip(index + 1) + .take(2) + .any(|candidate| is_collection_action(candidate))) + }) +} + +fn has_wide_collection_scope(tokens: &[&str]) -> bool { + tokens.iter().enumerate().any(|(index, token)| { + let compound = matches!( + *token, "drivewide" | "diskwide" | "volumewide" | "filesystemwide" | "sitewide" | "systemwide" - ) - }; - let is_scope_token = |token: &str| { - matches!( - token, + ) && tokens + .get(index + 1) + .is_some_and(|target| is_collection_target(target)); + let separated = matches!( + *token, "drive" | "disk" | "volume" | "filesystem" | "site" | "system" - ) - }; - let has_wide_scope = tokens.iter().any(|token| is_wide_scope_token(token)) - || tokens - .windows(2) - .any(|pair| is_scope_token(pair[0]) && pair[1] == "wide"); + ) && tokens.get(index + 1) == Some(&"wide") + && tokens + .get(index + 2) + .is_some_and(|target| is_collection_target(target)); - if has_wide_scope - || tokens.iter().any(|token| token.starts_with("recurs")) - || has_token(&["glob", "globs", "globbing"]) - { - return true; - } + (compound || separated) && has_nearby_collection_action_before(tokens, index) + }) +} - let has_broad_quantifier = has_token(&["all", "every", "entire", "whole", "full", "complete"]); - let has_filesystem_target = has_token(&[ - "file", - "files", - "directory", - "directories", - "folder", - "folders", - "drive", - "drives", - "disk", - "disks", - "volume", - "volumes", - "filesystem", - "filesystems", - ]); - if has_broad_quantifier && has_filesystem_target { - return true; - } +fn has_root_collection_scope(tokens: &[&str]) -> bool { + tokens.iter().enumerate().any(|(root_index, token)| { + if *token != "root" { + return false; + } + tokens.iter().enumerate().any(|(target_index, target)| { + is_collection_target(target) + && root_index.abs_diff(target_index) <= 2 + && has_nearby_collection_action_before(tokens, root_index.min(target_index)) + }) + }) +} - let has_root_target = has_token(&["root"]) - && has_token(&[ - "directory", - "directories", - "folder", - "folders", - "drive", - "drives", - "disk", - "disks", - "volume", - "volumes", - "filesystem", - "filesystems", - "path", - "paths", - ]); - if has_root_target { - return true; +fn has_broad_collection_scope(tokens: &[&str]) -> bool { + for (action_index, action) in tokens.iter().enumerate() { + if !is_collection_action(action) { + continue; + } + for broad_index in (action_index + 1)..tokens.len().min(action_index + 4) { + if !matches!( + tokens[broad_index], + "all" | "every" | "entire" | "whole" | "full" | "complete" + ) { + continue; + } + for target_index in (broad_index + 1)..tokens.len().min(broad_index + 4) { + if !is_collection_target(tokens[target_index]) { + continue; + } + if matches!(tokens[target_index], "disk" | "disks") + && tokens + .get(target_index + 1) + .is_some_and(|descriptor| matches!(*descriptor, "imaging" | "encryption")) + { + break; + } + return true; + } + } } - false } @@ -886,29 +976,14 @@ 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 +} + fn normalize_finding(finding: &mut SccmFinding) { - finding.finding_id = finding.finding_id.trim().to_owned(); finding.title = finding.title.trim().to_owned(); finding.summary = finding.summary.trim().to_owned(); - if let SccmPhase::Unknown(value) = &mut finding.phase { - *value = value.trim().to_owned(); - } - for reference in &mut finding.evidence { - normalize_evidence_reference(reference); - } - for terminal in &mut finding.terminal_evidence { - normalize_evidence_reference(&mut terminal.reference); - } - for key in &mut finding.correlation_keys { - if let Some(reference) = &mut key.evidence { - normalize_evidence_reference(reference); - } - } - for gap in &mut finding.coverage_gaps { - gap.artifact_id = gap.artifact_id.trim().to_owned(); - } for request in &mut finding.next_artifacts { - request.logical_id = request.logical_id.trim().to_owned(); request.reason = request.reason.trim().to_owned(); } @@ -928,11 +1003,6 @@ fn normalize_finding(finding: &mut SccmFinding) { finding.next_artifacts.dedup(); } -fn normalize_evidence_reference(reference: &mut SccmEvidenceRef) { - reference.artifact_id = reference.artifact_id.trim().to_owned(); - reference.entry_id = reference.entry_id.trim().to_owned(); -} - fn compare_evidence_refs(left: &SccmEvidenceRef, right: &SccmEvidenceRef) -> Ordering { left.artifact_id .cmp(&right.artifact_id) diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 2c031839c..b647f3127 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -127,6 +127,139 @@ const ROOTED_ARTIFACT_REQUEST_REASONS: [&str; 7] = [ "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 BOUNDED_NARRATIVE_ARTIFACT_REQUEST_REASONS: [&str; 5] = [ + "Collect the full disk imaging Task Sequence log.", + "Confirm the whole-disk encryption status recorded in PolicyAgent.log.", + "Confirm the system-wide assignment recorded in PolicyAgent.log.", + "Confirm recursive retry behavior recorded in PolicyAgent.log.", + "Confirm all files were downloaded, as recorded in PolicyAgent.log.", +]; + +#[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") @@ -652,7 +785,7 @@ fn finding_rejects_conflicting_ranges_for_one_logical_evidence_identity() { }); assert_eq!( mutated.validate().unwrap_err(), - SccmFindingValidationError::ConflictingEvidenceReference + SccmFindingValidationError::InvalidEvidenceReference ); } @@ -708,49 +841,110 @@ fn finding_serialization_prioritizes_conflicting_evidence_identity_ranges() { } #[test] -fn finding_canonicalizes_evidence_identity_whitespace() { - let top_level = SccmEvidenceRef { - artifact_id: " artifact-a ".into(), - entry_id: " entry-a ".into(), - line_start: Some(1), - line_end: Some(1), - }; - let terminal = SccmEvidenceRef { - artifact_id: "artifact-a ".into(), - entry_id: "entry-a".into(), - ..top_level.clone() - }; - let key = SccmEvidenceRef { - artifact_id: " artifact-a".into(), - entry_id: " entry-a".into(), - ..top_level.clone() - }; - let finding = SccmFindingBuilder::new("canonical-evidence-identity") - .class(SccmFindingClass::ConfirmedFailure) +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::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() - .unwrap(); + .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"); + } - assert_eq!(finding.evidence[0].artifact_id, "artifact-a"); - assert_eq!(finding.evidence[0].entry_id, "entry-a"); - assert_eq!(finding.terminal_evidence[0].reference, finding.evidence[0]); - assert_eq!( - finding.correlation_keys[0].evidence.as_ref(), - Some(&finding.evidence[0]) - ); + 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:#?}"); } #[test] @@ -764,7 +958,25 @@ fn finding_rejects_whitespace_wrapped_declared_phase_shadow() { .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) .build(); - assert!(result.is_err()); + 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] @@ -801,18 +1013,86 @@ fn finding_serialization_rejects_whitespace_wrapped_declared_phase_shadow() { } #[test] -fn finding_canonicalizes_future_phase_whitespace() { - let finding = SccmFindingBuilder::new("canonical-future-phase") +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()); - assert_eq!(finding.phase, SccmPhase::Unknown("futurePhase".into())); + 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" @@ -969,8 +1249,13 @@ fn finding_artifact_requests_reject_structurally_unbounded_reasons() { "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) { + for reason in reasons + .into_iter() + .chain(ROOTED_ARTIFACT_REQUEST_REASONS) + .chain(COMPACT_UNBOUNDED_ARTIFACT_REQUEST_REASONS) + { let result = SccmFindingBuilder::new("unbounded-structural-request") .class(SccmFindingClass::Symptom) .phase(SccmPhase::Policy) @@ -981,17 +1266,20 @@ fn finding_artifact_requests_reject_structurally_unbounded_reasons() { .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) .build(); - assert_eq!( - result.unwrap_err(), - SccmFindingValidationError::InvalidArtifactRequestReason, - "{reason}" - ); + 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() { - for reason in [ + let existing = [ "Confirm the bounded policy request outcome.", "Collect the PolicyAgent record cited by assignment A.", "Confirm the root cause recorded by PolicyAgent.", @@ -999,8 +1287,15 @@ fn finding_artifact_requests_accept_specific_bounded_reasons() { "Collect the disk imaging Task Sequence log.", "Collect Logs/PolicyAgent.log from the bounded bundle.", r"Collect Logs\PolicyAgent.log from the bounded bundle.", - ] { - SccmFindingBuilder::new("bounded-structural-request") + ]; + let canonical = finding_with_gap_and_request("bounded-reason-parity"); + let mut rejected = Vec::new(); + + for reason in existing + .into_iter() + .chain(BOUNDED_NARRATIVE_ARTIFACT_REQUEST_REASONS) + { + if SccmFindingBuilder::new("bounded-structural-request") .class(SccmFindingClass::Symptom) .phase(SccmPhase::Policy) .role(SccmRole::Client) @@ -1009,35 +1304,69 @@ fn finding_artifact_requests_accept_specific_bounded_reasons() { .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) .build() - .unwrap(); + .is_err() + { + rejected.push(format!("builder: {reason}")); + } + + let mut direct = canonical.clone(); + direct.next_artifacts[0].reason = reason.into(); + 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]["reason"] = serde_json::json!(reason); + 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"); - for reason in ROOTED_ARTIFACT_REQUEST_REASONS.into_iter().chain([ - "Collect every file on the system.", - "Scan the full disk for related evidence.", - ]) { + 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) + { + 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); - let deserialize_error = serde_json::from_value::(json) - .unwrap_err() - .to_string(); - assert!( - deserialize_error.contains("InvalidArtifactRequestReason"), - "{reason}: {deserialize_error}" - ); + 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(); - let serialize_error = serde_json::to_string(&mutated).unwrap_err().to_string(); - assert!( - serialize_error.contains("InvalidArtifactRequestReason"), - "{reason}: {serialize_error}" - ); + if serde_json::to_string(&mutated).is_ok() { + accepted.push(format!("serializer: {reason}")); + } } + + assert!( + accepted.is_empty(), + "accepted unbounded request boundaries: {accepted:#?}" + ); } #[test] @@ -1361,6 +1690,46 @@ fn finding_artifact_requests_require_nonempty_bounded_reasons_and_count() { ); } +#[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 [ From 802d28ef037b02f61b3e838c2cd9ac45efe48c07 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 21:19:03 -0400 Subject: [PATCH 039/422] test(sccm): harden issue 325 evidence contracts --- .../compliance/coverage-states/manifest.json | 2 +- .../inventory/coverage-states/manifest.json | 2 +- .../metering/coverage-states/manifest.json | 2 +- ...ry_compliance_metering_fixture_contract.rs | 452 ++++++++++++++++-- ...nt-inventory-compliance-metering-corpus.md | 7 + 5 files changed, 420 insertions(+), 45 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json index 75198dd6a..2a074d94f 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json @@ -84,7 +84,7 @@ "relativePath": "evidence/client-compliance/root-a/current/DCMAgent.log", "encoding": "utf-8", "collectionLimit": { - "byteLimit": 58, + "byteLimit": 59, "limitApplied": true }, "truncated": true diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json index 0ab1cb594..27620e65b 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json @@ -84,7 +84,7 @@ "relativePath": "evidence/client-inventory/root-c/current/InventoryAgentProvider.log", "encoding": "utf-8", "collectionLimit": { - "byteLimit": 57, + "byteLimit": 58, "limitApplied": true }, "truncated": true diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/manifest.json index 1e5418dc3..5e77e2c47 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/manifest.json @@ -84,7 +84,7 @@ "relativePath": "evidence/client-metering/root-c/current/SWMTRReportGen.log", "encoding": "utf-8", "collectionLimit": { - "byteLimit": 56, + "byteLimit": 57, "limitApplied": true }, "truncated": true 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 index 905da5ebe..36ef4b321 100644 --- 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 @@ -211,6 +211,64 @@ fn expected_profile(family: &str) -> Result<&'static str, String> { } } +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", "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() @@ -346,7 +404,7 @@ fn evidence_record_texts( scenario_root: &Path, artifacts_by_id: &BTreeMap, evidence_refs: &[Value], -) -> Result, String> { +) -> Result, String> { let mut records = Vec::new(); for evidence_ref in evidence_refs { let artifact_id = required_string(evidence_ref, "artifactId", "evidence reference")?; @@ -392,30 +450,45 @@ fn evidence_record_texts( let offset_minutes = entries[0] .timezone_offset .ok_or_else(|| format!("{artifact_id}:{} has no source offset", start + offset))?; + let timestamp = entries[0].timestamp.ok_or_else(|| { + format!( + "{artifact_id}:{} has no usable source timestamp", + start + offset + ) + })?; let source_version = required_string(artifact, "sourceVersion", artifact_id)?; records.push(( (*line).to_owned(), offset_minutes, source_version.to_owned(), + timestamp, )); } } Ok(records) } -fn record_contains_exact_key_pair(record: &str, field: &str, value: &str) -> bool { +fn record_exact_token_values<'a>(record: &'a str, field: &str) -> Vec<&'a str> { const MESSAGE_PREFIX: &str = ""; let Some(message_start) = record.find(MESSAGE_PREFIX) else { - return false; + return Vec::new(); }; let payload_start = message_start + MESSAGE_PREFIX.len(); let Some(message_end) = record[payload_start..].find(MESSAGE_SUFFIX) else { - return false; + return Vec::new(); }; record[payload_start..payload_start + message_end] .split_ascii_whitespace() - .any(|token| token.split_once('=') == Some((field, value))) + .filter_map(|token| { + let (name, value) = token.split_once('=')?; + (name == field).then_some(value) + }) + .collect() +} + +fn record_contains_exact_key_pair(record: &str, field: &str, value: &str) -> bool { + record_exact_token_values(record, field).contains(&value) } fn validate_contract( @@ -475,6 +548,18 @@ fn validate_contract( if artifact["role"] != "client" || artifact["kind"] != "ccmLog" { return Err(format!("{artifact_id} is not a client CCM artifact")); } + 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]) { @@ -543,14 +628,19 @@ fn validate_contract( if artifact["encoding"] != "utf-8" || std::str::from_utf8(&bytes).is_err() { return Err(format!("{artifact_id} is not declared and encoded UTF-8")); } - if capture_state == "capped" - && (artifact["collectionLimit"]["limitApplied"] != true + if capture_state == "capped" { + let byte_limit = artifact["collectionLimit"]["byteLimit"] + .as_u64() + .ok_or_else(|| format!("{artifact_id} capped byteLimit is not an integer"))?; + if artifact["collectionLimit"]["limitApplied"] != true || artifact["truncated"] != true - || artifact["rotation"]["fragmentComplete"] != false) - { - return Err(format!( - "{artifact_id} capped state lacks cap/partial provenance" - )); + || artifact["rotation"]["fragmentComplete"] != false + || byte_limit != declared_bytes + { + return Err(format!( + "{artifact_id} capped state is not an inclusive exact prefix" + )); + } } if capture_state == "parseFailed" && artifact["rotation"]["fragmentComplete"] != false { return Err(format!( @@ -734,7 +824,26 @@ fn validate_contract( } let claim = required_string(observation, "claim", observation_id)?; let lower_claim = claim.to_ascii_lowercase(); - if lower_claim.contains("server-side cause") || lower_claim.contains("proves") { + let causal_word = lower_claim + .split(|character: char| !character.is_ascii_alphanumeric()) + .any(|word| { + matches!( + word, + "cause" + | "caused" + | "causes" + | "causing" + | "causal" + | "because" + | "proves" + | "proof" + ) + }); + if causal_word + || ["root cause", "resulted in", "led to", "responsible for"] + .iter() + .any(|phrase| lower_claim.contains(phrase)) + { return Err(format!("{observation_id} makes a causal/proof claim")); } let artifact_ids = observation["artifactIds"] @@ -785,6 +894,7 @@ fn validate_contract( .as_array() .ok_or_else(|| "transactions are not an array".to_owned())?; let mut transaction_ids = BTreeSet::new(); + let mut scenario_semantics = Vec::new(); for transaction in transactions { let transaction_id = required_string(transaction, "transactionId", "transaction")?; if !transaction_ids.insert(transaction_id.to_owned()) { @@ -850,7 +960,7 @@ fn validate_contract( return Err(format!("{transaction_id} has no cited evidence")); } let records = evidence_record_texts(scenario_root, &artifacts_by_id, evidence_refs)?; - for (record, _, _) in &records { + for (record, _, _, _) in &records { for field in required_fields { let value = key[*field].as_str().expect("validated key string"); if !record_contains_exact_key_pair(record, field, value) { @@ -862,7 +972,7 @@ fn validate_contract( } if !records .iter() - .any(|(record, _, _)| record.contains(&format!("Phase={phase}"))) + .any(|(record, _, _, _)| record_contains_exact_key_pair(record, "Phase", phase)) { return Err(format!( "{transaction_id} phase {phase} is not bound to cited evidence" @@ -870,10 +980,9 @@ fn validate_contract( } let confidence = required_string(transaction, "confidence", transaction_id)?; - if records - .iter() - .any(|(_, offset, version)| offset.abs() > 1_439 || !version.starts_with("5.00.TEST.")) - { + if records.iter().any(|(_, offset, version, _)| { + offset.abs() > 1_439 || !version.starts_with("5.00.TEST.") + }) { return Err(format!( "{transaction_id} exact-key transaction lacks usable offset/profile provenance" )); @@ -881,12 +990,34 @@ fn validate_contract( 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.contains(&format!("Phase={phase}")) - && record.contains(&format!("Disposition={disposition}")) - && record.contains(&format!("Terminal={terminal}")) + records.iter().any(|(record, _, _, _)| { + record_contains_exact_key_pair(record, "Phase", phase) + && record_contains_exact_key_pair(record, "Disposition", disposition) + && record_contains_exact_key_pair( + record, + "Terminal", + if terminal { "true" } else { "false" }, + ) }) }; + let terminal_dispositions = records + .iter() + .filter(|(record, _, _, _)| { + record_contains_exact_key_pair(record, "Phase", phase) + && record_contains_exact_key_pair(record, "Terminal", "true") + }) + .flat_map(|(record, _, _, _)| { + record_exact_token_values(record, "Disposition") + .into_iter() + .map(str::to_owned) + }) + .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) { @@ -920,9 +1051,9 @@ fn validate_contract( || phase != "Evaluate" || confidence != "high" || !has_phase_record(disposition, true) - || !records - .iter() - .any(|(record, _, _)| record.contains("ResultType=Evaluation")) + || !records.iter().any(|(record, _, _, _)| { + record_contains_exact_key_pair(record, "ResultType", "Evaluation") + }) { return Err(format!( "{transaction_id} compliance evaluation result contract is invalid" @@ -939,6 +1070,31 @@ fn validate_contract( "{transaction_id} recovery lacks both terminal failure and success" )); } + let latest_failure = records + .iter() + .filter(|(record, _, _, _)| { + record_contains_exact_key_pair(record, "Phase", phase) + && record_contains_exact_key_pair(record, "Disposition", "Failed") + && record_contains_exact_key_pair(record, "Terminal", "true") + }) + .map(|(_, _, _, timestamp)| *timestamp) + .max() + .expect("terminal failure checked above"); + let earliest_success = records + .iter() + .filter(|(record, _, _, _)| { + record_contains_exact_key_pair(record, "Phase", phase) + && record_contains_exact_key_pair(record, "Disposition", "Succeeded") + && record_contains_exact_key_pair(record, "Terminal", "true") + }) + .map(|(_, _, _, timestamp)| *timestamp) + .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) @@ -980,6 +1136,11 @@ fn validate_contract( } validate_next_artifact(family, transaction_id, &transaction["nextArtifact"])?; } + if scenario_semantics != required_scenario_semantics(family, scenario)? { + return Err(format!( + "{family}/{scenario} required scenario semantics changed: {scenario_semantics:?}" + )); + } Ok(()) } @@ -998,6 +1159,25 @@ fn assert_rejected( ); } +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}" + ); +} + #[test] fn corpus_matrix_keeps_inventory_compliance_and_metering_separate() { assert_eq!( @@ -1039,6 +1219,55 @@ fn corpus_inventory_is_deterministic_and_documented() { ); } +#[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() { @@ -1214,6 +1443,21 @@ fn dynamic_manifest_mutations_cannot_escape_source_and_identity_boundaries() { ); } +#[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 dynamic_evidence_mutations_cannot_fabricate_exact_or_high_confidence_facts() { let (scenario_root, manifest, expected) = load_contract("inventory", "success"); @@ -1278,23 +1522,59 @@ fn dynamic_evidence_mutations_cannot_fabricate_exact_or_high_confidence_facts() } #[test] -fn exact_key_tokens_reject_lookalike_field_names_and_values() { - let value = "INV-REPORT-001"; - assert!(record_contains_exact_key_pair( - "", - "ReportId", - value - )); - for lookalike in [ - "", - "", - "", - "", +fn exact_message_tokens_reject_key_and_semantic_lookalikes() { + for (field, value) in [ + ("ReportId", "INV-REPORT-001"), + ("Phase", "Report"), + ("Disposition", "Succeeded"), + ("Terminal", "true"), + ("ResultType", "Evaluation"), ] { - assert!( - !record_contains_exact_key_pair(lookalike, "ReportId", value), - "look-alike key token was accepted: {lookalike}" - ); + let exact = format!(""); + assert!(record_contains_exact_key_pair(&exact, field, value)); + + for lookalike in [ + format!(""), + format!(""), + format!(""), + format!(""), + ] { + assert!( + !record_contains_exact_key_pair(&lookalike, field, 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" + ); + } } } @@ -1372,6 +1652,56 @@ fn dynamic_recovery_mutations_require_selected_profile_and_usable_offset() { ); } +#[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"); + 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) = @@ -1426,6 +1756,44 @@ fn dynamic_coverage_and_collision_mutations_remain_noncausal() { ); } +#[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, + "causal", + ); +} + +#[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) = 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 index 0dc8a3846..17c93e8f2 100644 --- a/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md +++ b/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md @@ -77,6 +77,13 @@ 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. +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; From 10d74280bfbec3379cd2885e2420dad1ccbe5503 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 21:27:39 -0400 Subject: [PATCH 040/422] fix(sccm): close finding scope and coverage gaps --- .../cmtraceopen-parser/src/sccm/findings.rs | 86 +++++++++------ .../tests/sccm_spine_contract.rs | 101 ++++++++++++++++++ 2 files changed, 156 insertions(+), 31 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 5ed1aeb32..ce9c0d09f 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -677,12 +677,22 @@ fn validate_evidence_reference( fn validate_coverage_gaps( coverage_gaps: &[SccmFindingCoverageGap], ) -> Result<(), SccmFindingValidationError> { - if coverage_gaps.iter().any(|gap| { - !is_canonical_opaque_id(&gap.artifact_id) + let mut coverage_by_artifact: BTreeMap<&str, &SccmFindingCoverageGap> = BTreeMap::new(); + for gap in coverage_gaps { + if !is_canonical_opaque_id(&gap.artifact_id) || gap.artifact_id.chars().count() > MAX_SCCM_COVERAGE_GAP_ARTIFACT_ID_CHARS || gap.coverage == SccmCoverageState::Captured - }) { - return Err(SccmFindingValidationError::InvalidCoverageGap); + { + 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(()) } @@ -859,33 +869,47 @@ fn has_root_collection_scope(tokens: &[&str]) -> bool { } fn has_broad_collection_scope(tokens: &[&str]) -> bool { - for (action_index, action) in tokens.iter().enumerate() { - if !is_collection_action(action) { - continue; - } - for broad_index in (action_index + 1)..tokens.len().min(action_index + 4) { - if !matches!( - tokens[broad_index], - "all" | "every" | "entire" | "whole" | "full" | "complete" - ) { - continue; - } - for target_index in (broad_index + 1)..tokens.len().min(broad_index + 4) { - if !is_collection_target(tokens[target_index]) { - continue; - } - if matches!(tokens[target_index], "disk" | "disks") - && tokens - .get(target_index + 1) - .is_some_and(|descriptor| matches!(*descriptor, "imaging" | "encryption")) - { - break; - } - return true; - } - } - } - false + let has_collection_target = tokens.iter().any(|token| is_collection_target(token)); + has_collection_target + && tokens.iter().enumerate().any(|(index, token)| { + is_broad_quantifier(token) && !is_reviewed_bounded_narrative(tokens, index) + }) +} + +fn is_broad_quantifier(token: &str) -> bool { + matches!( + token, + "all" | "every" | "entire" | "whole" | "full" | "complete" + ) +} + +fn is_reviewed_bounded_narrative(tokens: &[&str], quantifier_index: usize) -> bool { + let quantifier = tokens[quantifier_index]; + let bounded_disk_description = matches!(quantifier, "full" | "whole") + && tokens + .get(quantifier_index + 1) + .is_some_and(|target| matches!(*target, "disk" | "disks")) + && tokens + .get(quantifier_index + 2) + .is_some_and(|descriptor| matches!(*descriptor, "imaging" | "encryption")) + && has_specific_log_reference(tokens, quantifier_index + 3); + let downloaded_files_observation = quantifier == "all" + && tokens.get(quantifier_index + 1) == Some(&"files") + && tokens.get(quantifier_index + 2) == Some(&"were") + && tokens.get(quantifier_index + 3) == Some(&"downloaded") + && has_specific_log_reference(tokens, quantifier_index + 4); + + bounded_disk_description || downloaded_files_observation +} + +fn has_specific_log_reference(tokens: &[&str], start: usize) -> bool { + tokens[start..].windows(2).any(|pair| { + pair[1] == "log" + && !matches!( + pair[0], + "a" | "all" | "any" | "every" | "each" | "system" | "the" + ) + }) } fn validate_terminal_evidence( diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index b647f3127..7193a0ecb 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -135,6 +135,19 @@ const COMPACT_UNBOUNDED_ARTIFACT_REQUEST_REASONS: [&str; 5] = [ "Collect every log on the system.", ]; +const ORDER_INDEPENDENT_UNBOUNDED_ARTIFACT_REQUEST_REASONS: [&str; 10] = [ + "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.", +]; + const BOUNDED_NARRATIVE_ARTIFACT_REQUEST_REASONS: [&str; 5] = [ "Collect the full disk imaging Task Sequence log.", "Confirm the whole-disk encryption status recorded in PolicyAgent.log.", @@ -1255,6 +1268,7 @@ fn finding_artifact_requests_reject_structurally_unbounded_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) @@ -1342,6 +1356,7 @@ fn finding_artifact_request_bounds_apply_to_deserialization_and_serialization() "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(); @@ -1563,6 +1578,92 @@ fn finding_insufficient_evidence_requires_an_explicit_noncaptured_gap() { ); } +#[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 [ From a2beacdd1f81fb82fc5e0c1bcfd413eefc7bc621 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 21:32:49 -0400 Subject: [PATCH 041/422] test(sccm): prepare client management corpus --- .../fixtures/sccm/client/management/README.md | 14 + .../current/CoManagementHandler.log | 1 + .../co-management-intune-owned/expected.json | 40 + .../co-management-intune-owned/manifest.json | 38 + .../current/CoManagementHandler.log | 1 + .../co-management-sccm-owned/expected.json | 40 + .../co-management-sccm-owned/manifest.json | 38 + .../current/CoManagementHandler.log | 1 + .../co-management-transitioning/expected.json | 40 + .../co-management-transitioning/manifest.json | 38 + .../co-management-unknown/expected.json | 47 + .../co-management-unknown/manifest.json | 38 + .../current/CoManagementHandler.log | 2 + .../current/CcmNotificationAgent.log | 1 + .../client-scripts/root-a/current/Scripts.log | 1 + .../client-scripts/root-b/current/Scripts.log | 1 + .../management/mixed-unrelated/expected.json | 112 ++ .../management/mixed-unrelated/manifest.json | 126 ++ .../current/CoManagementHandler.log | 1 + .../current/CcmNotificationAgent.log | 2 + .../notification-deferred/expected.json | 75 + .../notification-deferred/manifest.json | 60 + .../current/CoManagementHandler.log | 1 + .../current/CcmNotificationAgent.log | 2 + .../notification-failure/expected.json | 72 + .../notification-failure/manifest.json | 60 + .../current/CoManagementHandler.log | 1 + .../current/CcmNotificationAgent.log | 2 + .../notification-received/expected.json | 72 + .../notification-received/manifest.json | 60 + .../current/CoManagementHandler.log | 1 + .../client-scripts/current/Scripts.log | 2 + .../management/script-failure/expected.json | 72 + .../management/script-failure/manifest.json | 60 + .../current/CoManagementHandler.log | 1 + .../client-scripts/current/Scripts.log | 1 + .../evidence/client-scripts/lo/Scripts.lo_ | 1 + .../script-incomplete/expected.json | 72 + .../script-incomplete/manifest.json | 82 + .../current/CoManagementHandler.log | 1 + .../client-scripts/current/Scripts.log | 1 + .../script-intune-handoff/expected.json | 56 + .../script-intune-handoff/manifest.json | 60 + .../current/CoManagementHandler.log | 1 + .../client-scripts/current/Scripts.log | 3 + .../management/script-success/expected.json | 72 + .../management/script-success/manifest.json | 60 + .../current/SCClient_SYNTHETIC_2.log | 1 + .../expected.json | 92 + .../manifest.json | 104 + .../current/CoManagementHandler.log | 1 + .../current/SCClient_SYNTHETIC_1.log | 1 + .../software-center-observed/expected.json | 56 + .../software-center-observed/manifest.json | 60 + ...sccm_client_management_fixture_contract.rs | 1777 +++++++++++++++++ .../issue-326-client-management-corpus.md | 199 ++ 56 files changed, 3824 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/README.md create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-intune-owned/evidence/client-co-management/current/CoManagementHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-intune-owned/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-intune-owned/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-sccm-owned/evidence/client-co-management/current/CoManagementHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-sccm-owned/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-sccm-owned/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-transitioning/evidence/client-co-management/current/CoManagementHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-transitioning/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-transitioning/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-unknown/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-unknown/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-co-management/current/CoManagementHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-notification/current/CcmNotificationAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-scripts/root-a/current/Scripts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/evidence/client-scripts/root-b/current/Scripts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/evidence/client-co-management/current/CoManagementHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/evidence/client-notification/current/CcmNotificationAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/evidence/client-co-management/current/CoManagementHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/evidence/client-notification/current/CcmNotificationAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/evidence/client-co-management/current/CoManagementHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/evidence/client-notification/current/CcmNotificationAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/evidence/client-co-management/current/CoManagementHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/evidence/client-scripts/current/Scripts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/evidence/client-co-management/current/CoManagementHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/evidence/client-scripts/current/Scripts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/evidence/client-scripts/lo/Scripts.lo_ create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-intune-handoff/evidence/client-co-management/current/CoManagementHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-intune-handoff/evidence/client-scripts/current/Scripts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-intune-handoff/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-intune-handoff/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/evidence/client-co-management/current/CoManagementHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/evidence/client-scripts/current/Scripts.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-insufficient/evidence/client-software-center/current/SCClient_SYNTHETIC_2.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-insufficient/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-insufficient/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/evidence/client-co-management/current/CoManagementHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/evidence/client-software-center/current/SCClient_SYNTHETIC_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs create mode 100644 docs/sccm/preparation/issue-326-client-management-corpus.md 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..47e94fa19 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-intune-owned/manifest.json @@ -0,0 +1,38 @@ +{ + "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/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 + } + } + ] +} 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..ce04b6946 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-sccm-owned/manifest.json @@ -0,0 +1,38 @@ +{ + "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/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 + } + } + ] +} 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..2f33dec77 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-transitioning/manifest.json @@ -0,0 +1,38 @@ +{ + "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/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 + } + } + ] +} 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..d4c109cb4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-unknown/manifest.json @@ -0,0 +1,38 @@ +{ + "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 + } + } + ] +} 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..9d568f726 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/mixed-unrelated/manifest.json @@ -0,0 +1,126 @@ +{ + "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/access/CcmNotificationAgent.log", + "pathFingerprint": "safe:path:326:mixed-notification-access", + "sourceVersion": null, + "encoding": null, + "rotation": { + "kind": "current", + "fragmentComplete": false + }, + "collectionLimit": { + "capped": false, + "limitBytes": null + } + }, + { + "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/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 + } + }, + { + "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/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 + } + }, + { + "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/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 + } + }, + { + "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/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 + } + } + ] +} 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..3744a8a13 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-deferred/manifest.json @@ -0,0 +1,60 @@ +{ + "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/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 + } + }, + { + "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/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 + } + } + ] +} 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..93ea122fe --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-failure/manifest.json @@ -0,0 +1,60 @@ +{ + "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/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 + } + }, + { + "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/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 + } + } + ] +} 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..5ef9e488e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/notification-received/manifest.json @@ -0,0 +1,60 @@ +{ + "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/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 + } + }, + { + "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/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 + } + } + ] +} 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..d5da1ce2f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-failure/manifest.json @@ -0,0 +1,60 @@ +{ + "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/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 + } + }, + { + "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/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 + } + } + ] +} 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..c4a723590 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-intune-handoff/manifest.json @@ -0,0 +1,60 @@ +{ + "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/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 + } + }, + { + "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/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 + } + } + ] +} 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..20926ecc3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-success/manifest.json @@ -0,0 +1,60 @@ +{ + "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/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 + } + }, + { + "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/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 + } + } + ] +} 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..0ee388bca --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-insufficient/manifest.json @@ -0,0 +1,104 @@ +{ + "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 + } + }, + { + "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/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 + } + }, + { + "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 + } + }, + { + "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/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 + } + } + ] +} 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..83126d82e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/manifest.json @@ -0,0 +1,60 @@ +{ + "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/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 + } + }, + { + "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/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 + } + } + ] +} 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..254199c6a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -0,0 +1,1777 @@ +use cmtraceopen_parser::models::log_entry::LogFormat; +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 { + message: String, + offset: Option, + 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 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 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, + 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.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, + source_name: &str, + sanitized_source_path: &str, +) -> Result<(), String> { + let required_prefix = format!("SYNTHETIC://client/management/{scenario}/"); + let suffix = sanitized_source_path + .strip_prefix(&required_prefix) + .unwrap_or_default(); + let lower_suffix = suffix.to_ascii_lowercase(); + if !sanitized_source_path.starts_with(&required_prefix) + || !sanitized_source_path.ends_with(source_name) + || sanitized_source_path.contains(['\\', '\n', '\r']) + || suffix + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + || lower_suffix.contains("%2e") + || suffix.contains(['?', '#']) + { + return Err(format!( + "source path {sanitized_source_path} is not bounded synthetic provenance" + )); + } + Ok(()) +} + +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 { + 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] { + let (entries, _) = + cmtraceopen_parser::parser::ccm::parse_content(line, artifact_id, None); + if entries.len() != 1 || entries[0].format != LogFormat::Ccm { + return Err(format!( + "{artifact_id} cited line is not one complete CCM logical record" + )); + } + records.push(EvidenceRecord { + message: entries[0].message.clone(), + offset: entries[0].timezone_offset, + source_version: required_string(artifact, "sourceVersion", artifact_id)?.to_owned(), + }); + } + } + 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() { + let (entries, _) = cmtraceopen_parser::parser::ccm::parse_content(line, artifact_id, None); + if entries.len() != 1 || entries[0].format != LogFormat::Ccm { + return Err(format!( + "{artifact_id} complete physical artifact has a malformed CCM record" + )); + } + records.push(EvidenceRecord { + message: entries[0].message.clone(), + offset: entries[0].timezone_offset, + source_version: required_string(artifact, "sourceVersion", artifact_id)?.to_owned(), + }); + } + Ok(records) +} + +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 validate_contract( + scenario: &str, + scenario_root: &Path, + manifest: &Value, + expected: &Value, +) -> Result<(), String> { + 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 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")?; + 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, 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, source_name, sanitized_source_path)?; + let path_fingerprint = required_string(artifact, "pathFingerprint", artifact_id)?; + if !path_fingerprint.starts_with("safe:path:326:") + || !path_fingerprints.insert(path_fingerprint.to_owned()) + { + 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.starts_with("5.00.TEST.") { + 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, source_name, source_path)?; + let path_fingerprint = + required_string(artifact, "pathFingerprint", artifact_id)?; + if !path_fingerprint.starts_with("safe:path:326:") + || !path_fingerprints.insert(path_fingerprint.to_owned()) + { + 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.offset.is_some_and(|offset| offset.abs() > 1_439)) + { + 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")?; + 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" => "mixedUnknownAndInvalid", + _ if !unknown_version_artifacts.is_empty() => "unknownProfile", + _ => "selected", + }; + let profile = &expected["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"]; + let ownership_class = required_string(ownership, "classification", "ownership")?; + let ownership_confidence = required_string(ownership, "confidence", "ownership")?; + 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 { + return Err("ownership evidence is not deterministically sorted".to_owned()); + } + if ownership_class != "UnknownOwnership" { + let workload = required_string(ownership, "workload", "ownership")?; + if ownership_records.is_empty() + || ownership_records.iter().any(|record| { + !record.message.contains(&format!("Workload={workload}")) + || !record + .message + .contains(&format!("Ownership={ownership_class}")) + }) + { + return Err("ownership classification is not bound to cited evidence".to_owned()); + } + if ownership_records.iter().any(|record| { + record.offset.is_none_or(|offset| offset.abs() > 1_439) + || !record.source_version.starts_with("5.00.TEST.") + }) { + return Err( + "ownership classification lacks usable timestamp/profile provenance".to_owned(), + ); + } + match ownership_class { + "SccmOwned" + if ownership_records.iter().any(|record| { + !record.message.contains("Disposition=Owned") + || !record.message.contains("Terminal=true") + }) => + { + return Err("SCCM ownership lacks terminal owned evidence".to_owned()); + } + "IntuneOwned" + if ownership_records.iter().any(|record| { + !record.message.contains("Disposition=Handoff") + || !record.message.contains("Terminal=true") + }) => + { + return Err("Intune ownership lacks terminal handoff evidence".to_owned()); + } + "SharedOrTransitioning" + if ownership_records.iter().any(|record| { + !record.message.contains("Disposition=Transitioning") + || !record.message.contains("Terminal=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.message.contains("Ownership=SccmOwned")) + || !ownership_records + .iter() + .any(|record| record.message.contains("Ownership=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(); + if ownership_gap_ids != sorted_ownership_gap_ids { + return Err("ownership coverage gaps are 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 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 mut transaction_ids = BTreeSet::new(); + let mut transaction_order = Vec::new(); + for transaction in transactions { + let transaction_id = required_string(transaction, "transactionId", "transaction")?; + 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_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 { + return Err(format!( + "{transaction_id} evidence references are not sorted" + )); + } + let records = evidence_records(scenario_root, &artifacts_by_id, &transaction["evidence"])?; + 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.message.contains(&format!("{field}={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 confidence == "high" + && records.iter().any(|record| { + record.offset.is_none_or(|offset| offset.abs() > 1_439) + || !record.source_version.starts_with("5.00.TEST.") + }) + { + return Err(format!( + "{transaction_id} high confidence lacks usable time/profile provenance" + )); + } + 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.message.contains(&format!("Phase={phase}")) + && record + .message + .contains(&format!("Disposition={disposition}")) + && record.message.contains(&format!("Terminal={terminal}")) + }) + }; + 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 + .message + .contains(&format!("Phase={last_successful_phase}")) + && record.message.contains("Disposition=Succeeded") + && record.message.contains("Terminal=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(); + if coverage_gap_ids != sorted_gap_ids { + return Err(format!("{transaction_id} coverage gaps are 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"))?; + 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_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")?; + 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 + .iter() + .any(|token| matches!(*token, "server" | "servers")) + || lower_claim.contains("intune failure") + { + return Err(format!( + "{observation_id} makes an unsupported causal claim" + )); + } + 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()); + } + } + match kind { + "coverageGap" + if artifact_ids.iter().any(|artifact_id| { + artifacts_by_id + .get(artifact_id.as_str()) + .is_some_and(|artifact| effective_state(artifact) == Ok("captured")) + }) => + { + 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(|token| record.message.contains(token)); + let has_notification_key = + ["NotificationId=", "ChannelId=", "ResourceHandle="] + .iter() + .all(|token| record.message.contains(token)); + 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:?}" + )); + } + 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() +} + +#[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 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 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:?}" + ); +} 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..90073841c --- /dev/null +++ b/docs/sccm/preparation/issue-326-client-management-corpus.md @@ -0,0 +1,199 @@ +# 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 selects the test profile, the CCM record is complete, and +its offset is usable. Unknown profiles and offsets outside ±1,439 minutes stay +source-local and noncorrelatable. + +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; +- sanitized source path and collision-safe path fingerprint when a candidate + path was observed; +- 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. + +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 and aliased cross-root path fingerprints; +- borrowed exact transaction keys; +- unversioned profile aliases and unknown-version promotion; +- capped coverage relabeled captured; +- invalid-offset evidence promoted to high confidence; +- 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. + +## Replay and acceptance limits + +Run the preparation target: + +```bash +cargo test --locked -p cmtraceopen-parser --test sccm_client_management_fixture_contract +``` + +Before review, also run: + +```bash +cargo test --locked -p cmtraceopen-parser --test sccm_spine_contract +cargo test --locked -p cmtraceopen-parser --test sccm_client_intake_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 +cargo fmt --check --all +git diff --check +``` + +These commands prove only deterministic pure-Rust fixture contracts and the +parser/wasm boundary. No native candidate discovery, permissions, Windows +layout, ConfigMgr version, Software Center filename class, notification +transport, Intune behavior, or live SCCM acceptance has been exercised. +Passing this preparation corpus is not an issue-closure condition. From 2ed50812786b862d7e2d781888caf7d137108745 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 21:47:14 -0400 Subject: [PATCH 042/422] test(sccm): enforce management evidence ordering --- ...sccm_client_management_fixture_contract.rs | 198 +++++++++++++++++- .../issue-326-client-management-corpus.md | 11 + 2 files changed, 201 insertions(+), 8 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 254199c6a..1829ace0b 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -43,6 +43,7 @@ struct CorpusInventory { #[derive(Debug)] struct EvidenceRecord { message: String, + timestamp: Option, offset: Option, source_version: String, } @@ -411,6 +412,7 @@ fn evidence_records( } records.push(EvidenceRecord { message: entries[0].message.clone(), + timestamp: entries[0].timestamp, offset: entries[0].timezone_offset, source_version: required_string(artifact, "sourceVersion", artifact_id)?.to_owned(), }); @@ -443,6 +445,7 @@ fn all_artifact_records( } records.push(EvidenceRecord { message: entries[0].message.clone(), + timestamp: entries[0].timestamp, offset: entries[0].timezone_offset, source_version: required_string(artifact, "sourceVersion", artifact_id)?.to_owned(), }); @@ -468,6 +471,79 @@ fn allowed_phases(workflow: &str) -> Result<&'static [&'static str], String> { } } +fn phase_rank(workflow: &str, message: &str) -> Result { + let phases = message + .split_ascii_whitespace() + .filter_map(|token| token.strip_prefix("Phase=")) + .collect::>(); + if phases.len() != 1 { + return Err("cited operational record does not have one exact phase".to_owned()); + } + allowed_phases(workflow)? + .iter() + .position(|phase| *phase == phases[0]) + .ok_or_else(|| format!("cited operational record has unknown phase {}", phases[0])) +} + +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.message) + .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)); + } + + if phase_bounds.keys().next() != Some(&0) + || phase_bounds.keys().next_back() != Some(&asserted_rank) + { + return Err(format!( + "{transaction_id} cited phases do not run from receive to the asserted phase" + )); + } + + 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, @@ -822,7 +898,8 @@ fn validate_contract( return Err("ownership classification is not bound to cited evidence".to_owned()); } if ownership_records.iter().any(|record| { - record.offset.is_none_or(|offset| offset.abs() > 1_439) + record.timestamp.is_none() + || record.offset.is_none_or(|offset| offset.abs() > 1_439) || !record.source_version.starts_with("5.00.TEST.") }) { return Err( @@ -906,6 +983,10 @@ fn validate_contract( 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_order = Vec::new(); for transaction in transactions { @@ -1024,16 +1105,24 @@ fn validate_contract( )); } let confidence = required_string(transaction, "confidence", transaction_id)?; - if confidence == "high" - && records.iter().any(|record| { - record.offset.is_none_or(|offset| offset.abs() > 1_439) - || !record.source_version.starts_with("5.00.TEST.") - }) - { + if records.iter().any(|record| { + record.timestamp.is_none() + || record.offset.is_none_or(|offset| offset.abs() > 1_439) + || !record.source_version.starts_with("5.00.TEST.") + }) { return Err(format!( - "{transaction_id} high confidence lacks usable time/profile provenance" + "{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| { @@ -1475,6 +1564,27 @@ fn management_corpus_inventory_and_digest_are_pinned() { ); } +#[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 { @@ -1726,6 +1836,78 @@ fn adversarial_key_profile_coverage_and_invalid_offset_mutations_fail_closed() { ); } +#[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(); diff --git a/docs/sccm/preparation/issue-326-client-management-corpus.md b/docs/sccm/preparation/issue-326-client-management-corpus.md index 90073841c..388604ff5 100644 --- a/docs/sccm/preparation/issue-326-client-management-corpus.md +++ b/docs/sccm/preparation/issue-326-client-management-corpus.md @@ -98,6 +98,9 @@ The preparation manifest is additive and does not reuse generic - `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. @@ -145,6 +148,8 @@ The focused Rust target dynamically proves that the validator rejects: - unversioned profile aliases and unknown-version promotion; - capped coverage relabeled captured; - invalid-offset evidence promoted to high confidence; +- terminal phases moved before receipt, ownership observed after an operational + transaction, and distinct phases assigned the same ambiguous timestamp; - a coherent attempt to mark Software Center candidates admitted and parser eligible; - a server-causal claim in client-only source-local output; and @@ -171,6 +176,12 @@ 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. + ## Replay and acceptance limits Run the preparation target: From 6d32e52d081f361dffd7f22d163e1e8afc67e654 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 21:47:56 -0400 Subject: [PATCH 043/422] test(sccm): enforce issue 325 provenance ordering --- .../compliance/coverage-states/manifest.json | 14 +- .../manifest.json | 6 +- .../noncompliant-result/manifest.json | 2 +- .../recovery-contradictory/manifest.json | 2 +- .../remediation-success/manifest.json | 6 +- .../same-minute-collision/manifest.json | 4 +- .../compliance/success/manifest.json | 4 +- .../terminal-failures/manifest.json | 6 +- .../inventory/coverage-states/manifest.json | 14 +- .../recovery-contradictory/manifest.json | 2 +- .../inventory/rotation-boundary/manifest.json | 4 +- .../same-minute-collision/manifest.json | 4 +- .../inventory/success/manifest.json | 6 +- .../inventory/terminal-failures/manifest.json | 6 +- .../metering/coverage-states/manifest.json | 14 +- .../recovery-contradictory/manifest.json | 2 +- .../metering/rotation-boundary/manifest.json | 4 +- .../same-minute-collision/manifest.json | 4 +- .../metering/success/manifest.json | 2 +- .../metering/terminal-failures/manifest.json | 2 +- ...ry_compliance_metering_fixture_contract.rs | 556 ++++++++++++++++-- ...nt-inventory-compliance-metering-corpus.md | 17 +- 22 files changed, 561 insertions(+), 120 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json index 2a074d94f..bae10018a 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json @@ -33,7 +33,7 @@ "fragmentComplete": false }, "sourceVersion": null, - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 0, "relativePath": null }, @@ -56,7 +56,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 0, "relativePath": null }, @@ -79,7 +79,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 59, "relativePath": "evidence/client-compliance/root-a/current/DCMAgent.log", "encoding": "utf-8", @@ -108,7 +108,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 0, "relativePath": null }, @@ -131,7 +131,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 0, "relativePath": null }, @@ -154,7 +154,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 42, "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", "encoding": "utf-8", @@ -182,7 +182,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 60, "relativePath": "evidence/client-compliance/root-a/current/CITaskMgr.log", "encoding": "utf-8", 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 index e16718cb8..cd4ce89b3 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 42, "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", "encoding": "utf-8", @@ -61,7 +61,7 @@ "fragmentComplete": true }, "sourceVersion": "9.99.UNKNOWN", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 329, "relativePath": "evidence/client-compliance/root-a/current/CITaskMgr.log", "encoding": "utf-8", @@ -89,7 +89,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 647, "relativePath": "evidence/client-compliance/root-a/current/DCMReporting.log", "encoding": "utf-8", 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 index df22fe7bd..ddfed32d8 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 327, "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", "encoding": "utf-8", 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 index 1ec9ae61a..ea59d8b2d 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 1331, "relativePath": "evidence/client-compliance/root-a/current/DCMReporting.log", "encoding": "utf-8", 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 index 7a0bad1f0..e79f7d2eb 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 328, "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", "encoding": "utf-8", @@ -61,7 +61,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 305, "relativePath": "evidence/client-compliance/root-a/current/DCMAgent.log", "encoding": "utf-8", @@ -89,7 +89,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 331, "relativePath": "evidence/client-compliance/root-a/current/DCMReporting.log", "encoding": "utf-8", 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 index 5e249f311..fc7af5029 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 327, "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", "encoding": "utf-8", @@ -61,7 +61,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 324, "relativePath": "evidence/client-compliance/root-b/current/CIAgent.log", "encoding": "utf-8", 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 index 0690569a8..2c7594340 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 325, "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", "encoding": "utf-8", @@ -61,7 +61,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 305, "relativePath": "evidence/client-compliance/root-a/current/DCMReporting.log", "encoding": "utf-8", 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 index 3b768c9ce..57a802237 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 320, "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", "encoding": "utf-8", @@ -61,7 +61,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 322, "relativePath": "evidence/client-compliance/root-a/current/DCMAgent.log", "encoding": "utf-8", @@ -89,7 +89,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 323, "relativePath": "evidence/client-compliance/root-a/current/DCMReporting.log", "encoding": "utf-8", diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json index 27620e65b..7e6a9cc9b 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json @@ -33,7 +33,7 @@ "fragmentComplete": false }, "sourceVersion": null, - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 0, "relativePath": null }, @@ -56,7 +56,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 0, "relativePath": null }, @@ -79,7 +79,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 58, "relativePath": "evidence/client-inventory/root-c/current/InventoryAgentProvider.log", "encoding": "utf-8", @@ -108,7 +108,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 0, "relativePath": null }, @@ -131,7 +131,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 0, "relativePath": null }, @@ -154,7 +154,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 41, "relativePath": "evidence/client-inventory/root-f/current/InventoryAgentProvider.log", "encoding": "utf-8", @@ -182,7 +182,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 59, "relativePath": "evidence/client-inventory/root-a/current/InventoryAgent.log", "encoding": "utf-8", 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 index 57e5fe873..34773a684 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 1334, "relativePath": "evidence/client-inventory/root-a/current/InventoryAgentProvider.log", "encoding": "utf-8", 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 index 0118c53f9..44dd2ba79 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 308, "relativePath": "evidence/client-inventory/root-a/lo/InventoryAgent.log.lo", "encoding": "utf-8", @@ -61,7 +61,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 314, "relativePath": "evidence/client-inventory/root-a/current/InventoryAgentProvider.log", "encoding": "utf-8", 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 index 6701c7ff8..8a67ee965 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 314, "relativePath": "evidence/client-inventory/root-a/current/InventoryAgentProvider.log", "encoding": "utf-8", @@ -61,7 +61,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 314, "relativePath": "evidence/client-inventory/root-b/current/InventoryAgentProvider.log", "encoding": "utf-8", 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 index d5786a3fb..0d5b770d8 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 308, "relativePath": "evidence/client-inventory/root-a/current/InventoryAgent.log", "encoding": "utf-8", @@ -61,7 +61,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 625, "relativePath": "evidence/client-inventory/root-a/current/InventoryProvider.log", "encoding": "utf-8", @@ -89,7 +89,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 628, "relativePath": "evidence/client-inventory/root-a/current/InventoryAgentProvider.log", "encoding": "utf-8", 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 index 9a617601b..abc3a943e 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 325, "relativePath": "evidence/client-inventory/root-a/current/InventoryAgent.log", "encoding": "utf-8", @@ -61,7 +61,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 659, "relativePath": "evidence/client-inventory/root-a/current/InventoryProvider.log", "encoding": "utf-8", @@ -89,7 +89,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 663, "relativePath": "evidence/client-inventory/root-a/current/InventoryAgentProvider.log", "encoding": "utf-8", diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/manifest.json index 5e77e2c47..c205c7779 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/manifest.json @@ -33,7 +33,7 @@ "fragmentComplete": false }, "sourceVersion": null, - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 0, "relativePath": null }, @@ -56,7 +56,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 0, "relativePath": null }, @@ -79,7 +79,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 57, "relativePath": "evidence/client-metering/root-c/current/SWMTRReportGen.log", "encoding": "utf-8", @@ -108,7 +108,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 0, "relativePath": null }, @@ -131,7 +131,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 0, "relativePath": null }, @@ -154,7 +154,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 40, "relativePath": "evidence/client-metering/root-f/current/SWMTRReportGen.log", "encoding": "utf-8", @@ -182,7 +182,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 58, "relativePath": "evidence/client-metering/root-g/current/SWMTRReportGen.log", "encoding": "utf-8", 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 index 9ba35c068..3ca45aba0 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 1370, "relativePath": "evidence/client-metering/root-a/current/SWMTRReportGen.log", "encoding": "utf-8", 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 index 1a78a8af3..d18b27828 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 327, "relativePath": "evidence/client-metering/root-a/lo/SWMTRReportGen.log.lo", "encoding": "utf-8", @@ -61,7 +61,7 @@ "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 323, "relativePath": "evidence/client-metering/root-a/current/SWMTRReportGen.log", "encoding": "utf-8", 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 index ea7d2647c..90f3add55 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 323, "relativePath": "evidence/client-metering/root-a/current/SWMTRReportGen.log", "encoding": "utf-8", @@ -61,7 +61,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 323, "relativePath": "evidence/client-metering/root-b/current/SWMTRReportGen.log", "encoding": "utf-8", 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 index d8f71fe0a..205d37f64 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 975, "relativePath": "evidence/client-metering/root-a/current/SWMTRReportGen.log", "encoding": "utf-8", 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 index 92273e8f1..d17ae4bd0 100644 --- 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 @@ -33,7 +33,7 @@ "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", - "capturedUtc": "2026-07-30T01:00:00Z", + "capturedUtc": "2026-07-30T04:00:00Z", "bytesCopied": 1027, "relativePath": "evidence/client-metering/root-a/current/SWMTRReportGen.log", "encoding": "utf-8", 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 index 36ef4b321..c477177fd 100644 --- 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 @@ -1,7 +1,11 @@ -use cmtraceopen_parser::models::log_entry::LogFormat; +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", @@ -314,6 +318,91 @@ fn walk_files(root: &Path) -> Result, String> { Ok(files) } +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 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() @@ -400,11 +489,96 @@ fn validate_next_artifact( Ok(()) } +fn expected_next_artifact( + family: &str, + phase: &str, + classification: &str, +) -> Result, String> { + if classification != "confirmedFailure" { + 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 expected_last_successful_phase( + family: &str, + phase: &str, + classification: &str, +) -> Result, String> { + let phases = admitted_phases(family)?; + let phase_index = phases + .iter() + .position(|candidate| *candidate == phase) + .ok_or_else(|| format!("{family} phase {phase} is not admitted"))?; + + match classification { + "confirmedFailure" => Ok(phase_index.checked_sub(1).map(|index| phases[index])), + "success" | "recovery" | "evaluationResult" => Ok(Some(phases[phase_index])), + "symptom" => Ok(None), + other => Err(format!( + "{family}/{phase} has unsupported last-success classification {other}" + )), + } +} + +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 { + raw_record: String, + source_version: String, + timestamp: SccmTimestamp, +} + fn evidence_record_texts( scenario_root: &Path, artifacts_by_id: &BTreeMap, evidence_refs: &[Value], -) -> Result, String> { +) -> Result, String> { let mut records = Vec::new(); for evidence_ref in evidence_refs { let artifact_id = required_string(evidence_ref, "artifactId", "evidence reference")?; @@ -419,6 +593,11 @@ fn evidence_record_texts( 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() @@ -435,34 +614,41 @@ fn evidence_record_texts( )); } for (offset, line) in lines[start - 1..end].iter().enumerate() { - let (entries, errors) = - cmtraceopen_parser::parser::ccm::parse_content(line, artifact_id, None); - if errors != 0 - || entries.len() != 1 - || entries[0].format != LogFormat::Ccm - || entries[0].line_number != 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!( "{artifact_id}:{} is not one complete CCM record", start + offset )); } - let offset_minutes = entries[0] - .timezone_offset - .ok_or_else(|| format!("{artifact_id}:{} has no source offset", start + offset))?; - let timestamp = entries[0].timestamp.ok_or_else(|| { - format!( - "{artifact_id}:{} has no usable source timestamp", + 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)?; - records.push(( - (*line).to_owned(), - offset_minutes, - source_version.to_owned(), + records.push(CitedEvidenceRecord { + raw_record: (*line).to_owned(), + source_version: source_version.to_owned(), timestamp, - )); + }); } } Ok(records) @@ -671,12 +857,9 @@ fn validate_contract( if capture_state == "captured" && artifact["rotation"]["fragmentComplete"] == true { let contents = std::str::from_utf8(&bytes).expect("validated UTF-8"); - let (entries, _) = - cmtraceopen_parser::parser::ccm::parse_content(contents, artifact_id, None); - if entries.iter().any(|entry| { - entry - .timezone_offset - .is_some_and(|offset| offset.abs() > 1_439) + 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()); } @@ -942,17 +1125,21 @@ fn validate_contract( "{transaction_id} has invalid {family} phase {phase}" )); } - if let Some(last_phase) = transaction["lastSuccessfulPhase"].as_str() { - if !phases.contains(&last_phase) { + 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 {last_phase} is invalid" + "{transaction_id} lastSuccessfulPhase is neither string nor null" )); - } - } else if !transaction["lastSuccessfulPhase"].is_null() { - return Err(format!( - "{transaction_id} lastSuccessfulPhase is neither string nor null" - )); - } + } else { + None + }; let evidence_refs = transaction["evidence"] .as_array() .ok_or_else(|| format!("{transaction_id} evidence is not an array"))?; @@ -960,10 +1147,10 @@ fn validate_contract( return Err(format!("{transaction_id} has no cited evidence")); } let records = evidence_record_texts(scenario_root, &artifacts_by_id, evidence_refs)?; - for (record, _, _, _) in &records { + for record in &records { for field in required_fields { let value = key[*field].as_str().expect("validated key string"); - if !record_contains_exact_key_pair(record, field, value) { + if !record_contains_exact_key_pair(&record.raw_record, field, value) { return Err(format!( "{transaction_id} {field} is not co-located in every cited CCM record" )); @@ -972,7 +1159,7 @@ fn validate_contract( } if !records .iter() - .any(|(record, _, _, _)| record_contains_exact_key_pair(record, "Phase", phase)) + .any(|record| record_contains_exact_key_pair(&record.raw_record, "Phase", phase)) { return Err(format!( "{transaction_id} phase {phase} is not bound to cited evidence" @@ -980,21 +1167,33 @@ fn validate_contract( } let confidence = required_string(transaction, "confidence", transaction_id)?; - if records.iter().any(|(_, offset, version, _)| { - offset.abs() > 1_439 || !version.starts_with("5.00.TEST.") - }) { + if records + .iter() + .any(|record| !record.source_version.starts_with("5.00.TEST.")) + { return Err(format!( - "{transaction_id} exact-key transaction lacks usable offset/profile provenance" + "{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 expected_last_successful_phase = + expected_last_successful_phase(family, phase, classification)?; + if last_successful_phase != expected_last_successful_phase { + return Err(format!( + "{transaction_id} lastSuccessfulPhase {last_successful_phase:?} != required {expected_last_successful_phase:?}" + )); + } let has_phase_record = |disposition: &str, terminal: bool| { - records.iter().any(|(record, _, _, _)| { - record_contains_exact_key_pair(record, "Phase", phase) - && record_contains_exact_key_pair(record, "Disposition", disposition) + records.iter().any(|record| { + record_contains_exact_key_pair(&record.raw_record, "Phase", phase) + && record_contains_exact_key_pair( + &record.raw_record, + "Disposition", + disposition, + ) && record_contains_exact_key_pair( - record, + &record.raw_record, "Terminal", if terminal { "true" } else { "false" }, ) @@ -1002,12 +1201,12 @@ fn validate_contract( }; let terminal_dispositions = records .iter() - .filter(|(record, _, _, _)| { - record_contains_exact_key_pair(record, "Phase", phase) - && record_contains_exact_key_pair(record, "Terminal", "true") + .filter(|record| { + record_contains_exact_key_pair(&record.raw_record, "Phase", phase) + && record_contains_exact_key_pair(&record.raw_record, "Terminal", "true") }) - .flat_map(|(record, _, _, _)| { - record_exact_token_values(record, "Disposition") + .flat_map(|record| { + record_exact_token_values(&record.raw_record, "Disposition") .into_iter() .map(str::to_owned) }) @@ -1051,8 +1250,12 @@ fn validate_contract( || phase != "Evaluate" || confidence != "high" || !has_phase_record(disposition, true) - || !records.iter().any(|(record, _, _, _)| { - record_contains_exact_key_pair(record, "ResultType", "Evaluation") + || !records.iter().any(|record| { + record_contains_exact_key_pair( + &record.raw_record, + "ResultType", + "Evaluation", + ) }) { return Err(format!( @@ -1072,22 +1275,48 @@ fn validate_contract( } let latest_failure = records .iter() - .filter(|(record, _, _, _)| { - record_contains_exact_key_pair(record, "Phase", phase) - && record_contains_exact_key_pair(record, "Disposition", "Failed") - && record_contains_exact_key_pair(record, "Terminal", "true") + .filter(|record| { + record_contains_exact_key_pair(&record.raw_record, "Phase", phase) + && record_contains_exact_key_pair( + &record.raw_record, + "Disposition", + "Failed", + ) + && record_contains_exact_key_pair( + &record.raw_record, + "Terminal", + "true", + ) + }) + .map(|record| { + record + .timestamp + .utc_millis + .expect("normalized timestamp checked during evidence loading") }) - .map(|(_, _, _, timestamp)| *timestamp) .max() .expect("terminal failure checked above"); let earliest_success = records .iter() - .filter(|(record, _, _, _)| { - record_contains_exact_key_pair(record, "Phase", phase) - && record_contains_exact_key_pair(record, "Disposition", "Succeeded") - && record_contains_exact_key_pair(record, "Terminal", "true") + .filter(|record| { + record_contains_exact_key_pair(&record.raw_record, "Phase", phase) + && record_contains_exact_key_pair( + &record.raw_record, + "Disposition", + "Succeeded", + ) + && record_contains_exact_key_pair( + &record.raw_record, + "Terminal", + "true", + ) + }) + .map(|record| { + record + .timestamp + .utc_millis + .expect("normalized timestamp checked during evidence loading") }) - .map(|(_, _, _, timestamp)| *timestamp) .min() .expect("terminal success checked above"); if earliest_success <= latest_failure { @@ -1135,6 +1364,22 @@ fn validate_contract( } } 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!( @@ -1458,6 +1703,188 @@ fn review_blocker_missing_capture_timestamp_is_rejected() { ); } +#[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 source offset cannot support ordered recovery." + })); + assert_rejected_with( + "recovery with invalid additive offset", + "inventory", + "recovery-contradictory", + &temporary.root, + &manifest, + &expected, + "normalized additive SCCM timestamp provenance", + ); +} + #[test] fn dynamic_evidence_mutations_cannot_fabricate_exact_or_high_confidence_facts() { let (scenario_root, manifest, expected) = load_contract("inventory", "success"); @@ -1677,6 +2104,7 @@ fn review_blocker_opposing_terminal_records_cannot_be_promoted_high() { 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", 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 index 17c93e8f2..ca74dd5ba 100644 --- a/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md +++ b/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md @@ -90,7 +90,13 @@ The expected contract keeps output deterministic and preparation-only: - every transaction is bound to one workflow and one versioned exact-key profile; - every evidence reference names a manifest artifact and valid line range; -- transaction citations contain complete raw CCM records; +- transaction citations contain complete raw CCM records whose additive SCCM + timestamp provenance normalizes to UTC no later than the artifact's canonical + `capturedUtc`; +- `lastSuccessfulPhase` follows the declared family phase order, and a + confirmed failure requests exactly the bounded next artifact for that 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 have a low confidence ceiling and are not correlation eligible; @@ -127,11 +133,18 @@ of: - 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; - promotion of missing coverage to captured evidence; - promotion of noncompliance to confirmed failure; - same-minute key borrowing between distinct root artifacts; - merging same-minute inventory and compliance terminal failures; -- unbounded next-artifact requests. +- missing, altered, or spurious next-artifact requests. This mutation layer is independent of the positive fixture assertions, so an internally consistent edit to both a manifest and its expected file cannot From 0a5305165306dbf06b934e3b824d499ce4cf89ce Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 21:49:05 -0400 Subject: [PATCH 044/422] docs(sccm): clarify management validation scope --- .../preparation/issue-326-client-management-corpus.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/sccm/preparation/issue-326-client-management-corpus.md b/docs/sccm/preparation/issue-326-client-management-corpus.md index 388604ff5..4340c07b8 100644 --- a/docs/sccm/preparation/issue-326-client-management-corpus.md +++ b/docs/sccm/preparation/issue-326-client-management-corpus.md @@ -203,8 +203,9 @@ cargo fmt --check --all git diff --check ``` -These commands prove only deterministic pure-Rust fixture contracts and the -parser/wasm boundary. No native candidate discovery, permissions, Windows -layout, ConfigMgr version, Software Center filename class, notification -transport, Intune behavior, or live SCCM acceptance has been exercised. -Passing this preparation corpus is not an issue-closure condition. +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. From e1691102a826c00dff388d9b7e43f17d9a4fa569 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 21:53:27 -0400 Subject: [PATCH 045/422] fix(sccm): bind artifact request scope --- .../cmtraceopen-parser/src/sccm/findings.rs | 305 +++++++++++++----- .../tests/sccm_spine_contract.rs | 220 +++++++++++-- 2 files changed, 430 insertions(+), 95 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index ce9c0d09f..5941fb858 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -709,9 +709,6 @@ fn validate_artifact_requests( if !is_canonical_opaque_id(&request.logical_id) { return Err(SccmFindingValidationError::UndeclaredArtifactRequest); } - if !is_bounded_request_reason(&request.reason) { - return Err(SccmFindingValidationError::InvalidArtifactRequestReason); - } let mut logical_matches = catalog .iter() @@ -719,18 +716,34 @@ fn validate_artifact_requests( let Some(first_match) = logical_matches.next() else { return Err(SccmFindingValidationError::UndeclaredArtifactRequest); }; - if first_match.role != request.role - && !logical_matches.any(|entry| entry.role == request.role) - { + 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) -> bool { +fn is_bounded_request_reason( + reason: &str, + requested_basename: &str, + requested_logical_id: &str, +) -> bool { let trimmed = reason.trim(); if trimmed.is_empty() + || !trimmed + .chars() + .any(|character| character.is_ascii_alphanumeric()) || trimmed.chars().count() > MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS || contains_rooted_path(trimmed) || trimmed.contains(['*', '?', '[', ']']) @@ -738,7 +751,11 @@ fn is_bounded_request_reason(reason: &str) -> bool { return false; } - !has_unbounded_request_scope(&trimmed.to_ascii_lowercase()) + let lowercase = trimmed.to_ascii_lowercase(); + 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 { @@ -753,8 +770,37 @@ fn contains_rooted_path(reason: &str) -> bool { }) } -fn has_unbounded_request_scope(reason: &str) -> bool { - let tokens = reason +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()) .filter(|token| !token.is_empty()) .collect::>(); @@ -766,7 +812,7 @@ fn has_unbounded_request_scope(reason: &str) -> bool { || has_recursive_collection_scope(&tokens) || has_wide_collection_scope(&tokens) || has_root_collection_scope(&tokens) - || has_broad_collection_scope(&tokens) + || has_unscoped_broad_collection_scope(&tokens, requested_basename, requested_logical_id) } fn is_collection_action(token: &str) -> bool { @@ -816,66 +862,72 @@ fn is_compact_unbounded_scope(token: &str) -> bool { .any(|prefix| token.strip_prefix(prefix).is_some_and(is_collection_target)) } -fn has_nearby_collection_action_before(tokens: &[&str], index: usize) -> bool { - let start = index.saturating_sub(4); - tokens[start..index] - .iter() - .any(|token| is_collection_action(token)) +fn is_collection_container(token: &str) -> bool { + matches!( + token, + "client" + | "device" + | "directory" + | "directories" + | "disk" + | "disks" + | "drive" + | "drives" + | "filesystem" + | "filesystems" + | "folder" + | "folders" + | "machine" + | "site" + | "system" + | "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 { - tokens.iter().enumerate().any(|(index, token)| { - token.starts_with("recurs") - && (has_nearby_collection_action_before(tokens, index) - || tokens - .iter() - .skip(index + 1) - .take(2) - .any(|candidate| is_collection_action(candidate))) - }) + 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 = matches!( - *token, - "drivewide" | "diskwide" | "volumewide" | "filesystemwide" | "sitewide" | "systemwide" - ) && tokens - .get(index + 1) - .is_some_and(|target| is_collection_target(target)); - let separated = matches!( - *token, - "drive" | "disk" | "volume" | "filesystem" | "site" | "system" - ) && tokens.get(index + 1) == Some(&"wide") + 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_target(target)); + .is_some_and(|target| is_collection_scope_subject(target)); - (compound || separated) && has_nearby_collection_action_before(tokens, index) + compound || separated }) } fn has_root_collection_scope(tokens: &[&str]) -> bool { tokens.iter().enumerate().any(|(root_index, token)| { - if *token != "root" { + if !token.starts_with("root") || tokens.get(root_index + 1) == Some(&"cause") { return false; } - tokens.iter().enumerate().any(|(target_index, target)| { - is_collection_target(target) - && root_index.abs_diff(target_index) <= 2 - && has_nearby_collection_action_before(tokens, root_index.min(target_index)) + tokens.iter().any(|candidate| { + is_collection_action(candidate) || is_collection_scope_subject(candidate) }) }) } -fn has_broad_collection_scope(tokens: &[&str]) -> bool { - let has_collection_target = tokens.iter().any(|token| is_collection_target(token)); - has_collection_target - && tokens.iter().enumerate().any(|(index, token)| { - is_broad_quantifier(token) && !is_reviewed_bounded_narrative(tokens, index) - }) -} - fn is_broad_quantifier(token: &str) -> bool { matches!( token, @@ -883,35 +935,138 @@ fn is_broad_quantifier(token: &str) -> bool { ) } -fn is_reviewed_bounded_narrative(tokens: &[&str], quantifier_index: usize) -> bool { - let quantifier = tokens[quantifier_index]; - let bounded_disk_description = matches!(quantifier, "full" | "whole") - && tokens - .get(quantifier_index + 1) - .is_some_and(|target| matches!(*target, "disk" | "disks")) - && tokens - .get(quantifier_index + 2) - .is_some_and(|descriptor| matches!(*descriptor, "imaging" | "encryption")) - && has_specific_log_reference(tokens, quantifier_index + 3); - let downloaded_files_observation = quantifier == "all" - && tokens.get(quantifier_index + 1) == Some(&"files") - && tokens.get(quantifier_index + 2) == Some(&"were") - && tokens.get(quantifier_index + 3) == Some(&"downloaded") - && has_specific_log_reference(tokens, quantifier_index + 4); - - bounded_disk_description || downloaded_files_observation -} - -fn has_specific_log_reference(tokens: &[&str], start: usize) -> bool { - tokens[start..].windows(2).any(|pair| { - pair[1] == "log" - && !matches!( - pair[0], - "a" | "all" | "any" | "every" | "each" | "system" | "the" +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 = normalize_catalog_identity(requested_basename); + 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::>(); + + if logical_id == "smsts" { + ranges.extend(tokens.windows(3).enumerate().filter_map(|(index, window)| { + (window == ["task", "sequence", "log"]).then_some((index, index + 3)) + })); + } + ranges +} + +fn normalize_catalog_identity(identity: &str) -> String { + identity + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .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, + "logs" => identity_precedes_target, + _ => true, + } +} + fn validate_terminal_evidence( evidence: &[SccmEvidenceRef], terminal_evidence: &[SccmTerminalEvidence], diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 7193a0ecb..ad89eba3b 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -135,7 +135,7 @@ const COMPACT_UNBOUNDED_ARTIFACT_REQUEST_REASONS: [&str; 5] = [ "Collect every log on the system.", ]; -const ORDER_INDEPENDENT_UNBOUNDED_ARTIFACT_REQUEST_REASONS: [&str; 10] = [ +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.", @@ -146,14 +146,94 @@ const ORDER_INDEPENDENT_UNBOUNDED_ARTIFACT_REQUEST_REASONS: [&str; 10] = [ "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_REQUEST_REASONS: [&str; 5] = [ - "Collect the full disk imaging Task Sequence log.", - "Confirm the whole-disk encryption status recorded in PolicyAgent.log.", - "Confirm the system-wide assignment recorded in PolicyAgent.log.", - "Confirm recursive retry behavior recorded in PolicyAgent.log.", - "Confirm all files were downloaded, as recorded in PolicyAgent.log.", +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.", + ), ]; #[derive(Clone, Copy, Debug)] @@ -1294,20 +1374,35 @@ fn finding_artifact_requests_reject_structurally_unbounded_reasons() { #[test] fn finding_artifact_requests_accept_specific_bounded_reasons() { let existing = [ - "Confirm the bounded policy request outcome.", - "Collect the PolicyAgent record cited by assignment A.", - "Confirm the root cause recorded by PolicyAgent.", - "Confirm the disk status code recorded in PolicyAgent.log.", - "Collect the disk imaging Task Sequence log.", - "Collect Logs/PolicyAgent.log from the bounded bundle.", - r"Collect Logs\PolicyAgent.log from the bounded bundle.", + ("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 reason in existing + for (logical_id, reason) in existing .into_iter() - .chain(BOUNDED_NARRATIVE_ARTIFACT_REQUEST_REASONS) + .chain(BOUNDED_NARRATIVE_ARTIFACT_REQUESTS) { if SccmFindingBuilder::new("bounded-structural-request") .class(SccmFindingClass::Symptom) @@ -1316,7 +1411,7 @@ fn finding_artifact_requests_accept_specific_bounded_reasons() { .severity(Severity::Warning) .confidence(SccmConfidence::Low) .evidence(vec![finding_evidence_ref("artifact-a", "entry-a")]) - .next_artifact(finding_request("policyAgent", SccmRole::Client, reason)) + .next_artifact(finding_request(logical_id, SccmRole::Client, reason)) .build() .is_err() { @@ -1324,7 +1419,7 @@ fn finding_artifact_requests_accept_specific_bounded_reasons() { } let mut direct = canonical.clone(); - direct.next_artifacts[0].reason = reason.into(); + direct.next_artifacts[0] = finding_request(logical_id, SccmRole::Client, reason); if direct.validate().is_err() { rejected.push(format!("direct validate: {reason}")); } @@ -1333,7 +1428,8 @@ fn finding_artifact_requests_accept_specific_bounded_reasons() { } let mut json = serde_json::to_value(&canonical).unwrap(); - json["nextArtifacts"][0]["reason"] = serde_json::json!(reason); + 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}")); } @@ -1384,6 +1480,90 @@ fn finding_artifact_request_bounds_apply_to_deserialization_and_serialization() ); } +#[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_rejects_a_correlation_key_without_an_evidence_ref() { let cited = finding_evidence_ref("client-policy-agent", "policy:1-1"); @@ -1723,7 +1903,7 @@ fn finding_artifact_requests_require_declared_logical_id_and_role() { #[test] fn finding_artifact_requests_require_nonempty_bounded_reasons_and_count() { - for reason in ["", " "] { + for reason in ["", " ", ".", ";", "!?"] { let result = SccmFindingBuilder::new("empty-request-reason") .class(SccmFindingClass::InsufficientEvidence) .phase(SccmPhase::Policy) From 15b3474f8721a3453e8732e264e0d20464e37288 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 22:14:12 -0400 Subject: [PATCH 046/422] test(sccm): prepare distribution point corpus --- .../sccm/server/distribution_point/README.md | 18 + .../absent-dp/expected.json | 20 + .../absent-dp/manifest.json | 41 + .../current/DataTransferService.log | 1 + .../client-only-looking-request/expected.json | 30 + .../client-only-looking-request/manifest.json | 45 + .../dp-02/current/SMSDPProv.log | 3 + .../dp/current/SMSDPProv.log | 3 + .../site/current/PkgXferMgr.log | 2 + .../site/current/distmgr.log | 4 + .../site/dp-02/current/PkgXferMgr.log | 1 + .../site/dp-02/current/distmgr.log | 2 + .../content-version-mismatch/expected.json | 83 + .../content-version-mismatch/manifest.json | 134 ++ .../site/current/distmgr.log | 2 + .../distribution-failure/expected.json | 35 + .../distribution-failure/manifest.json | 29 + .../dp/current/SMSDPProv.log | 3 + .../site/current/PkgXferMgr.log | 1 + .../site/current/distmgr.log | 2 + .../healthy-package/expected.json | 61 + .../healthy-package/manifest.json | 77 + .../site/current/distmgr.log | 2 + .../incomplete/expected.json | 40 + .../incomplete/manifest.json | 61 + .../dp/malformed/SMSDPProv.log | 1 + .../site/current/distmgr.log | 1 + .../site/lo_/distmgr.log | 1 + .../rotation-boundary/expected.json | 25 + .../rotation-boundary/manifest.json | 69 + .../dp/current/SMSDPProv.log | 2 + .../site/current/PkgXferMgr.log | 1 + .../site/current/distmgr.log | 2 + .../server-dp-serve/dp/current/SMSdpmon.log | 1 + .../serve-observed/expected.json | 42 + .../serve-observed/manifest.json | 89 + .../site/current/PkgXferMgr.log | 1 + .../site/current/distmgr.log | 2 + .../transfer-retry/expected.json | 37 + .../transfer-retry/manifest.json | 49 + .../dp/current/SMSDPProv.log | 1 + .../site/current/PkgXferMgr.log | 1 + .../site/current/distmgr.log | 2 + .../validation-failure/expected.json | 39 + .../validation-failure/manifest.json | 69 + ...ver_distribution_point_fixture_contract.rs | 1645 +++++++++++++++++ .../issue-329-distribution-point-corpus.md | 151 ++ 47 files changed, 2931 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/README.md create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/absent-dp/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/absent-dp/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/evidence/client-content-control/current/DataTransferService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/dp-02/current/SMSDPProv.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/dp/current/SMSDPProv.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/current/PkgXferMgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/current/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/dp-02/current/PkgXferMgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/dp-02/current/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/distribution-failure/evidence/server-dp-distribution/site/current/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/distribution-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/distribution-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/evidence/server-dp-distribution/dp/current/SMSDPProv.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/evidence/server-dp-distribution/site/current/PkgXferMgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/evidence/server-dp-distribution/site/current/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/incomplete/evidence/server-dp-distribution/site/current/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/incomplete/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/incomplete/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/evidence/server-dp-distribution/dp/malformed/SMSDPProv.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/evidence/server-dp-distribution/site/current/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/evidence/server-dp-distribution/site/lo_/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-distribution/dp/current/SMSDPProv.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-distribution/site/current/PkgXferMgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-distribution/site/current/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/evidence/server-dp-serve/dp/current/SMSdpmon.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/evidence/server-dp-distribution/site/current/PkgXferMgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/evidence/server-dp-distribution/site/current/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/evidence/server-dp-distribution/dp/current/SMSDPProv.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/evidence/server-dp-distribution/site/current/PkgXferMgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/evidence/server-dp-distribution/site/current/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs create mode 100644 docs/sccm/preparation/issue-329-distribution-point-corpus.md 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..1ef11fb2e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/README.md @@ -0,0 +1,18 @@ +# Synthetic Distribution Point fixture corpus + +This directory is test-only input for Issue `#329`. + +- 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 a preparation label, not a frozen production API. +- Exact package/content/version/DP/profile keys keep versions and DPs + independent. +- 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 focused Rust contract resolves every manifest path, runs captured CCM +files through the existing SCCM logical-record envelope, verifies normalized +timestamp/line provenance, 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..cebefbf55 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/absent-dp/expected.json @@ -0,0 +1,20 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "distributionPoint", + "scenario": "absent-dp", + "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], + "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, + "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, + "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, + "coverage": [ + {"artifactId": "dp-absent-01-distmgr", "state": "absent"}, + {"artifactId": "dp-absent-02-provider", "state": "absent"} + ], + "transactions": [], + "sourceLocalObservations": [], + "artifactRequests": [ + {"sourceId": "server-dp-distribution", "reasonCode": "coverageAbsent"} + ], + "clientCausalClaims": [], + "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} +} 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..e4d1cc788 --- /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", "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/client-only-looking-request/evidence/client-content-control/current/DataTransferService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/evidence/client-content-control/current/DataTransferService.log new file mode 100644 index 000000000..aa97c1886 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/evidence/client-content-control/current/DataTransferService.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/expected.json new file mode 100644 index 000000000..4cacb4db2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/expected.json @@ -0,0 +1,30 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "distributionPoint", + "scenario": "client-only-looking-request", + "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], + "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, + "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, + "roleAssessment": {"distributionPointObserved": false, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, + "coverage": [ + {"artifactId": "dp-client-control-01-data-transfer", "state": "captured"}, + {"artifactId": "dp-client-control-02-server-absent", "state": "absent"} + ], + "transactions": [], + "sourceLocalObservations": [ + { + "observationId": "client-control-01", + "classification": "ignoredClientEvidence", + "confidence": "low", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": ["dp-client-control-01-data-transfer"], + "evidence": [{"artifactId": "dp-client-control-01-data-transfer", "startLine": 1, "endLine": 1}] + } + ], + "artifactRequests": [ + {"sourceId": "server-dp-distribution", "reasonCode": "coverageAbsent"} + ], + "clientCausalClaims": [], + "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/manifest.json new file mode 100644 index 000000000..33055c5f4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/manifest.json @@ -0,0 +1,45 @@ +{ + "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", "rolesObserved": ["siteServer"]}, + "artifacts": [ + { + "artifactId": "dp-client-control-01-data-transfer", + "sourceId": "client-content-control", + "producerRole": "client", + "producerHostHandle": "safe:client:lab-client-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", + "sourceKind": "ccmLog", + "originalBasename": "DataTransferService.log", + "sanitizedSourcePath": "SYNTHETIC://client-control/Logs/DataTransferService.log", + "pathFingerprint": "synthetic:client-only-data-transfer", + "rotation": {"kind": "current", "lineageId": "client-only-data-transfer", "fragmentComplete": true}, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:20:00Z", + "encoding": "utf-8", + "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, + "bytesCopied": 386, + "relativePath": "evidence/client-content-control/current/DataTransferService.log" + }, + { + "artifactId": "dp-client-control-02-server-absent", + "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:client-only-server-absent", + "rotation": {"kind": "current", "lineageId": "client-only-server-absent"}, + "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/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..2db3daa9d --- /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,3 @@ + + + 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..b926b006a --- /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,3 @@ + + + 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..df2c54a87 --- /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..4089684ff --- /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/evidence/server-dp-distribution/site/dp-02/current/PkgXferMgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/dp-02/current/PkgXferMgr.log new file mode 100644 index 000000000..1ec58d395 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/dp-02/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/dp-02/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/dp-02/current/distmgr.log new file mode 100644 index 000000000..72c42e73a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/dp-02/current/distmgr.log @@ -0,0 +1,2 @@ + + 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..a37868c5b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/expected.json @@ -0,0 +1,83 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "distributionPoint", + "scenario": "content-version-mismatch", + "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], + "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, + "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, + "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, + "coverage": [ + {"artifactId": "dp-version-01-distmgr", "state": "captured"}, + {"artifactId": "dp-version-02-pkgxfer", "state": "captured"}, + {"artifactId": "dp-version-03-provider", "state": "captured"}, + {"artifactId": "dp-version-04-distmgr-dp02", "state": "captured"}, + {"artifactId": "dp-version-05-pkgxfer-dp02", "state": "captured"}, + {"artifactId": "dp-version-06-provider-dp02", "state": "captured"} + ], + "transactions": [ + { + "transactionId": "dp:LAB00005:content-epsilon:v1:safe:dp:lab-dp-01", + "key": {"packageId": "LAB00005", "contentId": "content-epsilon", "contentVersion": 1, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "succeeded", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "lastSuccessfulPhase": "serveOrReport", + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId": "01-v1-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 1, "endLine": 1}]}, + {"observationId": "02-v1-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 2, "endLine": 2}]}, + {"observationId": "03-v1-transfer", "phase": "transfer", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-02-pkgxfer", "startLine": 1, "endLine": 1}]}, + {"observationId": "04-v1-validate", "phase": "validate", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-03-provider", "startLine": 1, "endLine": 1}]}, + {"observationId": "05-v1-available", "phase": "makeAvailable", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-03-provider", "startLine": 2, "endLine": 2}]}, + {"observationId": "06-v1-report", "phase": "serveOrReport", "disposition": "succeeded", "terminal": true, "evidence": [{"artifactId": "dp-version-03-provider", "startLine": 3, "endLine": 3}]} + ] + }, + { + "transactionId": "dp:LAB00005:content-epsilon:v1:safe:dp:lab-dp-02", + "key": {"packageId": "LAB00005", "contentId": "content-epsilon", "contentVersion": 1, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-02", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "succeeded", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "lastSuccessfulPhase": "serveOrReport", + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId": "01-dp02-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-04-distmgr-dp02", "startLine": 1, "endLine": 1}]}, + {"observationId": "02-dp02-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-04-distmgr-dp02", "startLine": 2, "endLine": 2}]}, + {"observationId": "03-dp02-transfer", "phase": "transfer", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-05-pkgxfer-dp02", "startLine": 1, "endLine": 1}]}, + {"observationId": "04-dp02-validate", "phase": "validate", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-06-provider-dp02", "startLine": 1, "endLine": 1}]}, + {"observationId": "05-dp02-available", "phase": "makeAvailable", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-06-provider-dp02", "startLine": 2, "endLine": 2}]}, + {"observationId": "06-dp02-report", "phase": "serveOrReport", "disposition": "succeeded", "terminal": true, "evidence": [{"artifactId": "dp-version-06-provider-dp02", "startLine": 3, "endLine": 3}]} + ] + }, + { + "transactionId": "dp:LAB00005:content-epsilon:v2:safe:dp:lab-dp-01", + "key": {"packageId": "LAB00005", "contentId": "content-epsilon", "contentVersion": 2, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "deferred", + "classification": "blockedOrDeferred", + "confidence": "medium", + "confidenceCeiling": "medium", + "lastSuccessfulPhase": "distribute", + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId": "01-v2-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 3, "endLine": 3}]}, + {"observationId": "02-v2-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 4, "endLine": 4}]}, + {"observationId": "03-v2-transfer-retry", "phase": "transfer", "disposition": "retrying", "terminal": false, "evidence": [{"artifactId": "dp-version-02-pkgxfer", "startLine": 2, "endLine": 2}]} + ] + } + ], + "sourceLocalObservations": [], + "artifactRequests": [], + "clientCausalClaims": [], + "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} +} 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..278ef27b9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/manifest.json @@ -0,0 +1,134 @@ +{ + "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", + "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": 1404, + "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", + "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": 705, + "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": 1062, + "relativePath": "evidence/server-dp-distribution/dp/current/SMSDPProv.log" + }, + { + "artifactId": "dp-version-04-distmgr-dp02", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-02", + "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": 702, + "relativePath": "evidence/server-dp-distribution/site/dp-02/current/distmgr.log" + }, + { + "artifactId": "dp-version-05-pkgxfer-dp02", + "sourceId": "server-dp-distribution", + "producerRole": "siteServer", + "producerHostHandle": "safe:server:lab-pri-01", + "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-02", + "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": 353, + "relativePath": "evidence/server-dp-distribution/site/dp-02/current/PkgXferMgr.log" + }, + { + "artifactId": "dp-version-06-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": 1062, + "relativePath": "evidence/server-dp-distribution/dp-02/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..0b7aa917f --- /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..6db59d4f3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/distribution-failure/expected.json @@ -0,0 +1,35 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "distributionPoint", + "scenario": "distribution-failure", + "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], + "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, + "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, + "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, + "coverage": [ + {"artifactId": "dp-distribution-failure-01-distmgr", "state": "captured"} + ], + "transactions": [ + { + "transactionId": "dp:LAB00002:content-beta:v1:safe:dp:lab-dp-01", + "key": {"packageId": "LAB00002", "contentId": "content-beta", "contentVersion": 1, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "lastSuccessfulPhase": "receiveContent", + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId": "01-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-distribution-failure-01-distmgr", "startLine": 1, "endLine": 1}]}, + {"observationId": "02-distribute-failed", "phase": "distribute", "disposition": "failed", "terminal": true, "evidence": [{"artifactId": "dp-distribution-failure-01-distmgr", "startLine": 2, "endLine": 2}]} + ] + } + ], + "sourceLocalObservations": [], + "artifactRequests": [], + "clientCausalClaims": [], + "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} +} 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..5ad7144f4 --- /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", "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": 692, + "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..0474c8b44 --- /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,3 @@ + + + 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..85ad5dd18 --- /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..697091415 --- /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..5eacf62f1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/expected.json @@ -0,0 +1,61 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "distributionPoint", + "scenario": "healthy-package", + "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], + "analysisContract": { + "independentReducer": true, + "consumesClientOutput": false, + "crossSideCorrelationPerformed": false + }, + "extractionProfile": { + "selectionState": "selectedSynthetic", + "profileId": "dp-server-5.00.test-v1", + "validatedRole": "distributionPoint" + }, + "roleAssessment": { + "distributionPointObserved": true, + "roleAbsentInferred": false, + "missingDefaultPathInterpretation": "sourceCoverageOnly" + }, + "coverage": [ + {"artifactId": "dp-healthy-01-distmgr", "state": "captured"}, + {"artifactId": "dp-healthy-02-pkgxfer", "state": "captured"}, + {"artifactId": "dp-healthy-03-provider", "state": "captured"} + ], + "transactions": [ + { + "transactionId": "dp:LAB00001:content-alpha:v1:safe:dp:lab-dp-01", + "key": { + "packageId": "LAB00001", + "contentId": "content-alpha", + "contentVersion": 1, + "siteCode": "LAB", + "distributionPointHandle": "safe:dp:lab-dp-01", + "confidence": "exact", + "extractionProfileId": "dp-server-5.00.test-v1" + }, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "succeeded", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "lastSuccessfulPhase": "serveOrReport", + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId": "01-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-healthy-01-distmgr", "startLine": 1, "endLine": 1}]}, + {"observationId": "02-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-healthy-01-distmgr", "startLine": 2, "endLine": 2}]}, + {"observationId": "03-transfer", "phase": "transfer", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-healthy-02-pkgxfer", "startLine": 1, "endLine": 1}]}, + {"observationId": "04-validate", "phase": "validate", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-healthy-03-provider", "startLine": 1, "endLine": 1}]}, + {"observationId": "05-available", "phase": "makeAvailable", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-healthy-03-provider", "startLine": 2, "endLine": 2}]}, + {"observationId": "06-report", "phase": "serveOrReport", "disposition": "succeeded", "terminal": true, "evidence": [{"artifactId": "dp-healthy-03-provider", "startLine": 3, "endLine": 3}]} + ] + } + ], + "sourceLocalObservations": [], + "artifactRequests": [], + "clientCausalClaims": [], + "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} +} 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..8f6a931e5 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/healthy-package/manifest.json @@ -0,0 +1,77 @@ +{ + "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", + "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": 698, + "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": 351, + "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": 1056, + "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..e333a9589 --- /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..f0629e642 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/incomplete/expected.json @@ -0,0 +1,40 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "distributionPoint", + "scenario": "incomplete", + "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], + "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, + "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, + "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, + "coverage": [ + {"artifactId": "dp-incomplete-01-distmgr", "state": "captured"}, + {"artifactId": "dp-incomplete-02-pkgxfer-denied", "state": "accessDenied"}, + {"artifactId": "dp-incomplete-03-provider-absent", "state": "absent"} + ], + "transactions": [ + { + "transactionId": "dp:LAB00007:content-eta:v1:safe:dp:lab-dp-01", + "key": {"packageId": "LAB00007", "contentId": "content-eta", "contentVersion": 1, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "incomplete", + "classification": "insufficientEvidence", + "confidence": "low", + "confidenceCeiling": "low", + "lastSuccessfulPhase": "distribute", + "nextSourceId": "server-dp-distribution", + "coverageGapArtifactIds": ["dp-incomplete-02-pkgxfer-denied", "dp-incomplete-03-provider-absent"], + "observations": [ + {"observationId": "01-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-incomplete-01-distmgr", "startLine": 1, "endLine": 1}]}, + {"observationId": "02-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-incomplete-01-distmgr", "startLine": 2, "endLine": 2}]} + ] + } + ], + "sourceLocalObservations": [], + "artifactRequests": [ + {"sourceId": "server-dp-distribution", "reasonCode": "coverageAbsent"}, + {"sourceId": "server-dp-distribution", "reasonCode": "coverageAccessDenied"} + ], + "clientCausalClaims": [], + "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} +} 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..ae905b3aa --- /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", "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": 694, + "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/rotation-boundary/evidence/server-dp-distribution/dp/malformed/SMSDPProv.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/evidence/server-dp-distribution/dp/malformed/SMSDPProv.log new file mode 100644 index 000000000..520e8a220 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/evidence/server-dp-distribution/dp/malformed/SMSDPProv.log @@ -0,0 +1 @@ +SYNTHETIC FIXTURE MALFORMED CCM RECORD WITHOUT ATTRIBUTES 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..1afd42488 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/expected.json @@ -0,0 +1,25 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "distributionPoint", + "scenario": "rotation-boundary", + "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], + "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, + "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, + "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, + "coverage": [ + {"artifactId": "dp-rotation-01-current-fragment", "state": "captured"}, + {"artifactId": "dp-rotation-02-lo-fragment", "state": "captured"}, + {"artifactId": "dp-rotation-03-malformed", "state": "parseFailed"} + ], + "transactions": [], + "sourceLocalObservations": [ + {"observationId": "rotation-01-split", "classification": "rotationSplit", "confidence": "low", "confidenceCeiling": "low", "correlationEligible": false, "artifactIds": ["dp-rotation-01-current-fragment", "dp-rotation-02-lo-fragment"], "evidence": []}, + {"observationId": "rotation-02-malformed", "classification": "malformedEvidence", "confidence": "low", "confidenceCeiling": "low", "correlationEligible": false, "artifactIds": ["dp-rotation-03-malformed"], "evidence": []} + ], + "artifactRequests": [ + {"sourceId": "server-dp-distribution", "reasonCode": "coverageMalformed"}, + {"sourceId": "server-dp-distribution", "reasonCode": "coverageRotationSplit"} + ], + "clientCausalClaims": [], + "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} +} 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..844006abb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/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", "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-current", + "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.log", + "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.lo_", + "pathFingerprint": "synthetic:rotation-lo", + "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" + }, + { + "artifactId": "dp-rotation-03-malformed", + "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:rotation-malformed", + "rotation": {"kind": "current", "lineageId": "rotation-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/malformed/SMSDPProv.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..5ff2d1528 --- /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..e7df5a40b --- /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..26acda749 --- /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..8a93861fc --- /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..80e87d824 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/serve-observed/expected.json @@ -0,0 +1,42 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "distributionPoint", + "scenario": "serve-observed", + "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], + "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, + "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, + "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, + "coverage": [ + {"artifactId": "dp-serve-01-distmgr", "state": "captured"}, + {"artifactId": "dp-serve-02-pkgxfer", "state": "captured"}, + {"artifactId": "dp-serve-03-provider", "state": "captured"}, + {"artifactId": "dp-serve-04-status", "state": "captured"} + ], + "transactions": [ + { + "transactionId": "dp:LAB00006:content-zeta:v1:safe:dp:lab-dp-01", + "key": {"packageId": "LAB00006", "contentId": "content-zeta", "contentVersion": 1, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "succeeded", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "lastSuccessfulPhase": "serveOrReport", + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId": "01-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-serve-01-distmgr", "startLine": 1, "endLine": 1}]}, + {"observationId": "02-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-serve-01-distmgr", "startLine": 2, "endLine": 2}]}, + {"observationId": "03-transfer", "phase": "transfer", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-serve-02-pkgxfer", "startLine": 1, "endLine": 1}]}, + {"observationId": "04-validate", "phase": "validate", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-serve-03-provider", "startLine": 1, "endLine": 1}]}, + {"observationId": "05-available", "phase": "makeAvailable", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-serve-03-provider", "startLine": 2, "endLine": 2}]}, + {"observationId": "06-serve-observed", "phase": "serveOrReport", "disposition": "succeeded", "terminal": true, "evidence": [{"artifactId": "dp-serve-04-status", "startLine": 1, "endLine": 1}]} + ] + } + ], + "sourceLocalObservations": [], + "artifactRequests": [], + "clientCausalClaims": [], + "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} +} 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..494aea4a1 --- /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", "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": 696, + "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": 350, + "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": 701, + "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": 350, + "relativePath": "evidence/server-dp-serve/dp/current/SMSdpmon.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..154901bed --- /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..b0d629f5c --- /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..428e529ba --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-retry/expected.json @@ -0,0 +1,37 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "distributionPoint", + "scenario": "transfer-retry", + "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], + "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, + "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, + "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, + "coverage": [ + {"artifactId": "dp-transfer-retry-01-distmgr", "state": "captured"}, + {"artifactId": "dp-transfer-retry-02-pkgxfer", "state": "captured"} + ], + "transactions": [ + { + "transactionId": "dp:LAB00003:content-gamma:v1:safe:dp:lab-dp-01", + "key": {"packageId": "LAB00003", "contentId": "content-gamma", "contentVersion": 1, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "deferred", + "classification": "blockedOrDeferred", + "confidence": "medium", + "confidenceCeiling": "medium", + "lastSuccessfulPhase": "distribute", + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId": "01-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-transfer-retry-01-distmgr", "startLine": 1, "endLine": 1}]}, + {"observationId": "02-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-transfer-retry-01-distmgr", "startLine": 2, "endLine": 2}]}, + {"observationId": "03-transfer-retry", "phase": "transfer", "disposition": "retrying", "terminal": false, "evidence": [{"artifactId": "dp-transfer-retry-02-pkgxfer", "startLine": 1, "endLine": 1}]} + ] + } + ], + "sourceLocalObservations": [], + "artifactRequests": [], + "clientCausalClaims": [], + "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} +} 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..f7773f185 --- /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", "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": 698, + "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": 350, + "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..65223a49b --- /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..4b42203ad --- /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..c80962a06 --- /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..dc8309516 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/validation-failure/expected.json @@ -0,0 +1,39 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "distributionPoint", + "scenario": "validation-failure", + "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], + "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, + "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, + "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, + "coverage": [ + {"artifactId": "dp-validation-failure-01-distmgr", "state": "captured"}, + {"artifactId": "dp-validation-failure-02-pkgxfer", "state": "captured"}, + {"artifactId": "dp-validation-failure-03-provider", "state": "captured"} + ], + "transactions": [ + { + "transactionId": "dp:LAB00004:content-delta:v1:safe:dp:lab-dp-01", + "key": {"packageId": "LAB00004", "contentId": "content-delta", "contentVersion": 1, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, + "topologyCompatibility": "exact", + "correlationEligible": true, + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "confidenceCeiling": "high", + "lastSuccessfulPhase": "transfer", + "nextSourceId": null, + "coverageGapArtifactIds": [], + "observations": [ + {"observationId": "01-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-validation-failure-01-distmgr", "startLine": 1, "endLine": 1}]}, + {"observationId": "02-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-validation-failure-01-distmgr", "startLine": 2, "endLine": 2}]}, + {"observationId": "03-transfer", "phase": "transfer", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-validation-failure-02-pkgxfer", "startLine": 1, "endLine": 1}]}, + {"observationId": "04-validate-failed", "phase": "validate", "disposition": "failed", "terminal": true, "evidence": [{"artifactId": "dp-validation-failure-03-provider", "startLine": 1, "endLine": 1}]} + ] + } + ], + "sourceLocalObservations": [], + "artifactRequests": [], + "clientCausalClaims": [], + "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} +} 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..a69dd593e --- /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", "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": 698, + "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": 351, + "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": 345, + "relativePath": "evidence/server-dp-distribution/dp/current/SMSDPProv.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs new file mode 100644 index 000000000..58cb4752e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -0,0 +1,1645 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::DateTime; +use cmtraceopen_parser::sccm::{ + normalize_ccm_artifact, SccmArtifact, SccmCoverageState, SccmEvidence, SccmRole, SccmRotation, + SccmTimeOrderingState, +}; +use serde_json::{json, Value}; + +const SCENARIOS: &[&str] = &[ + "absent-dp", + "client-only-looking-request", + "content-version-mismatch", + "distribution-failure", + "healthy-package", + "incomplete", + "rotation-boundary", + "serve-observed", + "transfer-retry", + "validation-failure", +]; + +const STATE_CHAIN: &[&str] = &[ + "receiveContent", + "distribute", + "transfer", + "validate", + "makeAvailable", + "serveOrReport", +]; + +const EXACT_PROFILE: &str = "dp-server-5.00.test-v1"; +const EXACT_SITE: &str = "LAB"; +const EXACT_DP: &str = "safe:dp:lab-dp-01"; + +fn corpus_root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/distribution_point") +} + +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 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), + "distributionPoint" => Ok(SccmRole::DistributionPoint), + 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()) + .map(SccmRotation::Numbered) + .ok_or_else(|| "numbered rotation requires a u32 value".to_owned()), + "timestamped" => required_string(rotation, "value", "rotation") + .map(str::to_owned) + .map(SccmRotation::Timestamped), + other => Err(format!("unsupported fixture rotation {other}")), + } +} + +fn allowed_source(source_id: &str, role: &str, basename: &str) -> bool { + matches!( + (source_id, role, basename), + ("server-dp-distribution", "siteServer", "distmgr.log") + | ("server-dp-distribution", "siteServer", "PkgXferMgr.log") + | ( + "server-dp-distribution", + "distributionPoint", + "SMSDPProv.log" + ) + | ("server-dp-distribution", "distributionPoint", "PullDP.log") + | ("server-dp-serve", "distributionPoint", "SMSdpmon.log") + | ( + "client-content-control", + "client", + "DataTransferService.log" + ) + ) +} + +fn phase_allowed_for_artifact(artifact: &ParsedArtifact, phase: &str) -> bool { + matches!( + ( + artifact.source_id.as_str(), + artifact.basename.as_str(), + phase + ), + ( + "server-dp-distribution", + "distmgr.log", + "receiveContent" | "distribute" + ) | ("server-dp-distribution", "PkgXferMgr.log", "transfer") + | ( + "server-dp-distribution", + "PullDP.log", + "receiveContent" | "transfer" + ) + | ( + "server-dp-distribution", + "SMSDPProv.log", + "validate" | "makeAvailable" | "serveOrReport" + ) + | ("server-dp-serve", "SMSdpmon.log", "serveOrReport") + ) +} + +#[derive(Debug)] +struct ParsedArtifact { + state: String, + source_id: String, + role: String, + basename: String, + fragment_complete: Option, +} + +#[derive(Debug)] +struct ParsedScenario { + artifacts: BTreeMap, + evidence: BTreeMap<(String, u32, u32), SccmEvidence>, + distribution_point_handles: BTreeSet, +} + +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", + "PackageId", + "ContentId", + "ContentVersion", + "SiteCode", + "DpHandle", + "ProfileId", + "ClientHandle", + "RequestId", + ]; + 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('/').any(|segment| segment == "..") + && relative_path + .rsplit('/') + .next() + .is_some_and(|candidate| candidate == basename) +} + +fn validate_manifest( + scenario_root: &std::path::Path, + manifest: &Value, +) -> Result> { + let mut failures = Vec::new(); + reject_unknown_fields( + manifest, + &[ + "sccmManifestVersion", + "proposalOnly", + "syntheticFixture", + "bundle", + "topology", + "artifacts", + ], + "manifest", + &mut failures, + ); + reject_unknown_fields( + &manifest["bundle"], + &["bundleRole", "workflow", "capturedUtc"], + "bundle", + &mut failures, + ); + reject_unknown_fields( + &manifest["topology"], + &[ + "siteCode", + "distributionPointHandle", + "distributionPointHandles", + "rolesObserved", + ], + "topology", + &mut failures, + ); + if manifest["sccmManifestVersion"] != 1 + || manifest["proposalOnly"] != true + || manifest["syntheticFixture"] != true + || manifest["bundle"]["bundleRole"] != "server" + || manifest["bundle"]["workflow"] != "distributionPoint" + { + failures + .push("manifest does not retain the versioned synthetic server boundary".to_owned()); + } + if manifest["topology"]["siteCode"] != EXACT_SITE + || manifest["topology"]["distributionPointHandle"] != EXACT_DP + { + failures.push("manifest topology is not the exact synthetic LAB DP".to_owned()); + } + let mut distribution_point_handles = manifest["topology"]["distributionPointHandles"] + .as_array() + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_else(|| vec![EXACT_DP.to_owned()]); + let original_handle_order = distribution_point_handles.clone(); + distribution_point_handles.sort(); + distribution_point_handles.dedup(); + if distribution_point_handles != original_handle_order + || !distribution_point_handles + .iter() + .any(|handle| handle == EXACT_DP) + || distribution_point_handles + .iter() + .any(|handle| !handle.starts_with("safe:dp:")) + { + failures.push( + "distributionPointHandles must be sorted, unique, opaque, and include the primary DP" + .to_owned(), + ); + } + let distribution_point_handles = distribution_point_handles + .into_iter() + .collect::>(); + + let roles = manifest["topology"]["rolesObserved"] + .as_array() + .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 roles != sorted_roles || !roles.contains(&"siteServer") { + failures + .push("rolesObserved must be sorted, unique, and retain the site server".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 deterministically 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 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}"); + let source_id = match required_string(artifact, "sourceId", &context) { + Ok(value) => value, + Err(error) => { + failures.push(error); + continue; + } + }; + let role = match required_string(artifact, "producerRole", &context) { + Ok(value) => value, + Err(error) => { + failures.push(error); + continue; + } + }; + let basename = match required_string(artifact, "originalBasename", &context) { + Ok(value) => value, + Err(error) => { + failures.push(error); + continue; + } + }; + let state = match required_string(artifact, "captureState", &context) { + Ok(value) => value, + Err(error) => { + failures.push(error); + continue; + } + }; + 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, + ); + } + if !allowed_source(source_id, role, basename) { + failures.push(format!( + "{artifact_id} has an uncatalogued source/producer/basename combination" + )); + } + if artifact["workflowSubjectRole"] != "distributionPoint" + || !artifact["workflowSubjectHandle"] + .as_str() + .is_some_and(|handle| distribution_point_handles.contains(handle)) + { + failures.push(format!( + "{artifact_id} loses the distribution-point workflow subject" + )); + } + if !artifact["producerHostHandle"] + .as_str() + .is_some_and(|value| value.starts_with("safe:")) + { + failures.push(format!("{artifact_id} lacks an opaque producer handle")); + } + if role == "distributionPoint" + && artifact["producerHostHandle"] != artifact["workflowSubjectHandle"] + { + failures.push(format!( + "{artifact_id} DP producer does not match its exact workflow subject" + )); + } + if !artifact["pathFingerprint"] + .as_str() + .is_some_and(|value| value.starts_with("synthetic:")) + || !artifact["sanitizedSourcePath"] + .as_str() + .is_some_and(|value| value.starts_with("SYNTHETIC://")) + { + failures.push(format!("{artifact_id} leaks or omits path provenance")); + } + if artifact["pathFingerprint"] + .as_str() + .is_some_and(|value| !path_fingerprints.insert(value.to_owned())) + { + failures.push(format!( + "{artifact_id} collapses a path fingerprint collision" + )); + } + if artifact["sourceKind"] != "ccmLog" + || !artifact["sourceVersion"] + .as_str() + .is_some_and(|value| value.starts_with("5.00.TEST.")) + { + failures.push(format!( + "{artifact_id} is outside the synthetic CCM/profile source boundary" + )); + } + + 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_from_manifest(&artifact["rotation"]) { + Ok(value) => value, + Err(error) => { + failures.push(format!("{artifact_id}: {error}")); + continue; + } + }; + + if matches!(state, "captured" | "capped" | "parseFailed") { + 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 !relative_paths.insert(relative_path.to_owned()) { + failures.push(format!( + "{artifact_id} collides with another physical evidence destination" + )); + } + let fixture_path = scenario_root.join(relative_path); + let bytes = 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 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-limit provenance" + )); + } + if !String::from_utf8_lossy(&bytes).contains("SYNTHETIC FIXTURE") { + failures.push(format!("{artifact_id} lacks a synthetic fixture marker")); + } + if matches!(state, "captured" | "capped") { + 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: Some( + required_string(artifact, "collectedUtc", &context) + .unwrap_or_default() + .to_owned(), + ), + rotation: rotation_model.clone(), + coverage: coverage_model.clone(), + encoding: artifact["encoding"].as_str().map(str::to_owned), + }; + let normalized = normalize_ccm_artifact(artifact_model, &content); + if artifact["rotation"]["fragmentComplete"] == false && !normalized.is_empty() { + failures.push(format!( + "{artifact_id} exposes a logical record from an incomplete rotation 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() + { + failures.push(format!( + "{artifact_id} has unusable timestamp provenance in a transaction-capable record" + )); + } else if record + .timestamp + .utc_millis + .is_some_and(|value| value > captured_utc) + { + failures.push(format!( + "{artifact_id} cites evidence later than the canonical bundle capture" + )); + } + let artifact_collected_utc = artifact["collectedUtc"] + .as_str() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.timestamp_millis()); + if artifact_collected_utc.is_none() + || artifact_collected_utc.is_some_and(|value| value > captured_utc) + || 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 incoherent evidence/artifact/bundle 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" + )); + } + if let Err(error) = parse_fixture_fields(&record.message) { + 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.clone()).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["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(), + basename: basename.to_owned(), + 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, + distribution_point_handles, + }) + } 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, + distribution_point_handles: &BTreeSet, +) -> Result, String> { + let mut fields = BTreeMap::new(); + for (json_field, record_field) in [ + ("packageId", "PackageId"), + ("contentId", "ContentId"), + ("contentVersion", "ContentVersion"), + ("siteCode", "SiteCode"), + ("distributionPointHandle", "DpHandle"), + ("extractionProfileId", "ProfileId"), + ] { + let value = if json_field == "contentVersion" { + key[json_field] + .as_u64() + .map(|value| value.to_string()) + .ok_or_else(|| format!("{context}.{json_field} must be a u64"))? + } else { + required_string(key, json_field, context)?.to_owned() + }; + fields.insert(record_field, value); + } + if fields["SiteCode"] != EXACT_SITE + || !distribution_point_handles.contains(&fields["DpHandle"]) + || fields["ProfileId"] != EXACT_PROFILE + || key["confidence"] != "exact" + { + return Err(format!( + "{context} is outside the exact synthetic key 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, + ); + reject_unknown_fields( + &expected["analysisContract"], + &[ + "independentReducer", + "consumesClientOutput", + "crossSideCorrelationPerformed", + ], + "analysisContract", + &mut failures, + ); + reject_unknown_fields( + &expected["extractionProfile"], + &["selectionState", "profileId", "validatedRole"], + "extractionProfile", + &mut failures, + ); + reject_unknown_fields( + &expected["roleAssessment"], + &[ + "distributionPointObserved", + "roleAbsentInferred", + "missingDefaultPathInterpretation", + ], + "roleAssessment", + &mut failures, + ); + reject_unknown_fields( + &expected["correlationHandoff"], + &["issue", "performed", "timeOnlyEligible"], + "correlationHandoff", + &mut failures, + ); + if expected["contractState"] != "proposedPendingReviewed318And335" + || expected["workflow"] != "distributionPoint" + || 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()); + } + if expected["stateChain"] + .as_array() + .map(|values| values.iter().filter_map(Value::as_str).collect::>()) + .as_deref() + != Some(STATE_CHAIN) + { + failures.push("expected state chain does not match Task 5".to_owned()); + } + if expected["extractionProfile"]["profileId"] != EXACT_PROFILE + || expected["extractionProfile"]["selectionState"] != "selectedSynthetic" + || expected["extractionProfile"]["validatedRole"] != "distributionPoint" + { + failures + .push("expected output lacks the versioned synthetic extraction profile".to_owned()); + } + if expected["roleAssessment"]["roleAbsentInferred"] != false + || expected["roleAssessment"]["missingDefaultPathInterpretation"] != "sourceCoverageOnly" + { + failures.push("expected output infers role state from source coverage".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 row", &mut failures); + let Ok(artifact_id) = required_string(row, "artifactId", "coverage row") else { + failures.push("coverage row lacks artifactId".to_owned()); + continue; + }; + let Ok(state) = required_string(row, "state", "coverage row") else { + failures.push(format!("{artifact_id} coverage row lacks state")); + continue; + }; + coverage_order.push(artifact_id.to_owned()); + 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(); + if coverage_order != sorted_coverage { + failures.push("coverage rows are not deterministically sorted".to_owned()); + } + if declared_coverage != expected_coverage { + failures.push(format!( + "coverage is not the exact physical manifest projection: {declared_coverage:?} != {expected_coverage:?}" + )); + } + + 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(); + for transaction in transactions { + let transaction_id = match required_string(transaction, "transactionId", "transaction") { + Ok(value) => value, + Err(error) => { + failures.push(error); + continue; + } + }; + 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"], + &[ + "packageId", + "contentId", + "contentVersion", + "siteCode", + "distributionPointHandle", + "confidence", + "extractionProfileId", + ], + &format!("{transaction_id}.key"), + &mut failures, + ); + let key_fields = match exact_key_fields( + &transaction["key"], + &format!("{transaction_id}.key"), + &parsed.distribution_point_handles, + ) { + Ok(value) => value, + Err(error) => { + failures.push(error); + continue; + } + }; + let expected_id = format!( + "dp:{}:{}:v{}:{}", + key_fields["PackageId"], + key_fields["ContentId"], + key_fields["ContentVersion"], + key_fields["DpHandle"] + ); + if transaction_id != expected_id { + failures.push(format!( + "{transaction_id} is not 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 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 deterministically sorted" + )); + } + + let mut latest_success: Option = None; + let mut terminal_success = false; + let mut terminal_failure = false; + let mut terminal_deferred = false; + let mut previous_utc = i64::MIN; + let mut previous_phase = 0usize; + for observation in observations { + let observation_id = + required_string(observation, "observationId", transaction_id).unwrap_or("invalid"); + 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() { + failures.push(format!("{observation_id} uses unsupported phase {phase}")); + } + if phase_index.is_some_and(|index| index < previous_phase) { + failures.push(format!( + "{transaction_id} phases move backward despite increasing evidence time" + )); + } + 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 cited_artifact_id = + required_string(reference, "artifactId", observation_id).unwrap_or("invalid"); + match parsed.artifacts.get(cited_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 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}={expected_value}" + )); + } + } + 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 ordered by normalized UTC provenance" + )); + } + 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) => terminal_deferred = 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"); + match (state, classification) { + ("succeeded", "success") + if terminal_success + && computed_last_success == Some("serveOrReport") + && !terminal_failure + && confidence == "high" + && confidence_ceiling == "high" => {} + ("failed", "confirmedFailure") + if terminal_failure + && !terminal_success + && confidence == "high" + && confidence_ceiling == "high" => {} + ("deferred", "blockedOrDeferred") + if terminal_deferred + && !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 the required terminal evidence" + )), + } + + let gap_ids = transaction["coverageGapArtifactIds"] + .as_array() + .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(); + if gap_ids != sorted_gap_ids { + failures.push(format!("{transaction_id} coverage gaps are not sorted")); + } + for artifact_id in gap_ids { + match parsed.artifacts.get(artifact_id) { + Some(artifact) if artifact.state != "captured" => {} + _ => failures.push(format!( + "{transaction_id} coverage gap {artifact_id} is absent or complete" + )), + } + } + 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} terminal/deferred state invents a next source" + )); + } + } + + 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 deterministically sorted".to_owned()); + } + for observation in source_local { + let observation_id = + required_string(observation, "observationId", "sourceLocalObservation") + .unwrap_or("invalid"); + reject_unknown_fields( + observation, + &[ + "observationId", + "classification", + "confidence", + "confidenceCeiling", + "correlationEligible", + "artifactIds", + "evidence", + ], + observation_id, + &mut failures, + ); + if !matches!( + observation["classification"].as_str(), + Some("ignoredClientEvidence" | "rotationSplit" | "malformedEvidence") + ) || observation.get("key").is_some() + || observation["correlationEligible"] != false + || observation["confidence"] != "low" + || observation["confidenceCeiling"] != "low" + { + failures.push(format!( + "{observation_id} is not an explicitly noncorrelatable source-local observation" + )); + } + let artifact_ids = observation["artifactIds"] + .as_array() + .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(); + if artifact_ids.is_empty() || artifact_ids != sorted_artifact_ids { + failures.push(format!( + "{observation_id} lacks sorted physical artifact provenance" + )); + } + for artifact_id in artifact_ids { + if !parsed.artifacts.contains_key(artifact_id) { + failures.push(format!( + "{observation_id} cites unknown physical artifact {artifact_id}" + )); + } + } + for reference in observation["evidence"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + { + reject_unknown_fields( + reference, + &["artifactId", "startLine", "endLine"], + &format!("{observation_id}.evidence"), + &mut failures, + ); + if let Err(error) = evidence_for(parsed, reference, observation_id) { + failures.push(error); + } + } + } + + 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 { + let source_id = + required_string(request, "sourceId", "artifactRequest").unwrap_or("invalid"); + let reason_code = + required_string(request, "reasonCode", "artifactRequest").unwrap_or("invalid"); + reject_unknown_fields( + request, + &["sourceId", "reasonCode"], + "artifactRequest", + &mut failures, + ); + request_order.push((source_id, reason_code)); + if !matches!(source_id, "server-dp-distribution" | "server-dp-serve") + || !matches!( + reason_code, + "coverageAbsent" + | "coverageAccessDenied" + | "coverageCapped" + | "coverageMalformed" + | "coverageRotationSplit" + ) + || request.get("reason").is_some() + { + failures.push(format!( + "artifact request is not a bounded versioned source/reason code: {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 !matching_coverage { + failures.push(format!( + "artifact request {source_id}/{reason_code} lacks matching noncomplete coverage" + )); + } + } + let mut sorted_request_order = request_order.clone(); + sorted_request_order.sort_unstable(); + if request_order != sorted_request_order { + failures.push("artifact requests are not deterministically sorted".to_owned()); + } + + if expected["clientCausalClaims"] != json!([]) + || expected["correlationHandoff"]["issue"] != "#333" + || expected["correlationHandoff"]["performed"] != false + || expected["correlationHandoff"]["timeOnlyEligible"] != false + { + failures.push( + "expected output makes or enables a premature cross-side causal claim".to_owned(), + ); + } + + if scenario == "absent-dp" + && (expected["roleAssessment"]["distributionPointObserved"] != true + || !transactions.is_empty()) + { + failures.push("absent-dp must retain the observed role without a diagnosis".to_owned()); + } + if scenario == "client-only-looking-request" + && (!transactions.is_empty() + || !parsed + .artifacts + .values() + .any(|artifact| artifact.role == "client")) + { + failures.push("client-only evidence entered a DP transaction".to_owned()); + } + if scenario == "rotation-boundary" && !transactions.is_empty() { + failures.push("rotation fragments formed a DP transaction".to_owned()); + } + if scenario == "content-version-mismatch" { + let versions = transactions + .iter() + .filter_map(|transaction| transaction["key"]["contentVersion"].as_u64()) + .collect::>(); + let dp_handles = transactions + .iter() + .filter_map(|transaction| { + transaction["key"]["distributionPointHandle"] + .as_str() + .map(str::to_owned) + }) + .collect::>(); + if versions != BTreeSet::from([1, 2]) + || dp_handles + != BTreeSet::from([ + "safe:dp:lab-dp-01".to_owned(), + "safe:dp:lab-dp-02".to_owned(), + ]) + || transactions.len() != 3 + { + failures.push( + "content/version/DP topology did not remain three exact transactions".to_owned(), + ); + } + } + + if manifest["topology"]["rolesObserved"] + .as_array() + .is_some_and(|roles| roles.iter().any(|role| role == "distributionPoint")) + != expected["roleAssessment"]["distributionPointObserved"] + .as_bool() + .unwrap_or(false) + { + failures.push("role assessment is not an exact topology projection".to_owned()); + } + + if failures.is_empty() { + Ok(()) + } else { + Err(failures) + } +} + +fn validate_scenario_values( + scenario: &str, + manifest: &Value, + expected: &Value, +) -> Result<(), Vec> { + let scenario_root = corpus_root().join(scenario); + let parsed = validate_manifest(&scenario_root, manifest)?; + validate_expected(scenario, manifest, expected, &parsed) +} + +#[test] +fn distribution_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 Task 5 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"))); + } +} + +fn mutation_was_accepted(scenario: &str, manifest: &Value, expected: &Value) -> bool { + validate_scenario_values(scenario, manifest, expected).is_ok() +} + +#[test] +fn exact_content_version_dp_topology_and_terminal_evidence_fail_closed() { + let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut version_alias = healthy_expected.clone(); + version_alias["transactions"][0]["key"]["contentVersion"] = json!(2); + if mutation_was_accepted("healthy-package", &healthy_manifest, &version_alias) { + accepted.push("transaction content version diverged from cited evidence"); + } + + let mut dp_alias = healthy_expected.clone(); + dp_alias["transactions"][0]["key"]["distributionPointHandle"] = json!("safe:dp:lab-dp-02"); + if mutation_was_accepted("healthy-package", &healthy_manifest, &dp_alias) { + accepted.push("transaction DP topology diverged from cited evidence"); + } + + let mut time_only_cause = healthy_expected.clone(); + time_only_cause["clientCausalClaims"] = + json!(["A same-time client request proves the DP caused the failure."]); + if mutation_was_accepted("healthy-package", &healthy_manifest, &time_only_cause) { + accepted.push("time-only client/DP causality was admitted"); + } + + let failure_manifest = + read_json("distribution-failure", "manifest.json").expect("manifest loads"); + let mut failure_expected = + read_json("distribution-failure", "expected.json").expect("expected loads"); + failure_expected["transactions"][0]["observations"][1]["terminal"] = json!(false); + if mutation_was_accepted("distribution-failure", &failure_manifest, &failure_expected) { + accepted.push("confirmed failure survived without cited terminal evidence"); + } + + assert!( + accepted.is_empty(), + "exact key/causality/terminal mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn coverage_role_and_rotation_states_fail_closed() { + let absent_manifest = read_json("absent-dp", "manifest.json").expect("manifest loads"); + let absent_expected = read_json("absent-dp", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut promoted_coverage = absent_expected.clone(); + promoted_coverage["coverage"][0]["state"] = json!("captured"); + if mutation_was_accepted("absent-dp", &absent_manifest, &promoted_coverage) { + accepted.push("absent source coverage was promoted to captured"); + } + + let mut missing_role = absent_expected.clone(); + missing_role["roleAssessment"]["distributionPointObserved"] = json!(false); + missing_role["roleAssessment"]["roleAbsentInferred"] = json!(true); + if mutation_was_accepted("absent-dp", &absent_manifest, &missing_role) { + accepted.push("missing source path was promoted to missing DP role"); + } + + let mut role_alias_manifest = absent_manifest.clone(); + role_alias_manifest["artifacts"][0]["producerRole"] = json!("distributionPoint"); + if mutation_was_accepted("absent-dp", &role_alias_manifest, &absent_expected) { + accepted.push("basename reclassified the site-server producer as a DP"); + } + + 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 healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let mut invented_transaction = rotation_expected.clone(); + invented_transaction["transactions"] = healthy_expected["transactions"].clone(); + if mutation_was_accepted( + "rotation-boundary", + &rotation_manifest, + &invented_transaction, + ) { + accepted.push("split rotation fragments formed a transaction"); + } + + assert!( + accepted.is_empty(), + "coverage/role/rotation mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn client_only_and_version_mismatch_controls_stay_independent() { + let client_manifest = + read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); + let client_expected = + read_json("client-only-looking-request", "expected.json").expect("expected loads"); + let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut server_transaction = client_expected.clone(); + server_transaction["transactions"] = healthy_expected["transactions"].clone(); + if mutation_was_accepted( + "client-only-looking-request", + &client_manifest, + &server_transaction, + ) { + accepted.push("client-only record entered the server DP reducer"); + } + + let mismatch_manifest = + read_json("content-version-mismatch", "manifest.json").expect("manifest loads"); + let mismatch_expected = + read_json("content-version-mismatch", "expected.json").expect("expected loads"); + let mut merged_versions = mismatch_expected.clone(); + merged_versions["transactions"] + .as_array_mut() + .expect("transactions are mutable") + .pop(); + if mutation_was_accepted( + "content-version-mismatch", + &mismatch_manifest, + &merged_versions, + ) { + accepted.push("same content across two versions collapsed into one transaction"); + } + + assert!( + accepted.is_empty(), + "client/version separation mutations were accepted: {accepted:?}" + ); +} + +#[test] +fn structured_fixture_fields_are_unique_closed_and_record_local() { + let valid = "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=transfer; Disposition=succeeded; Terminal=false; PackageId=LAB00001; ContentId=content-alpha; ContentVersion=1; SiteCode=LAB; DpHandle=safe:dp:lab-dp-01; ProfileId=dp-server-5.00.test-v1"; + assert!(parse_fixture_fields(valid).is_ok()); + + for invalid in [ + "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=transfer; Phase=validate; Disposition=succeeded; Terminal=false", + "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=transfer; Disposition=succeeded; Terminal=false; Terminal=true", + "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=transfer; Disposition=succeeded; Terminal=false; ServerCause=network", + "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=transfer]LOG]!>; Disposition=succeeded; Terminal=false", + ] { + assert!( + parse_fixture_fields(invalid).is_err(), + "ambiguous or unsupported fields were accepted: {invalid}" + ); + } +} + +#[test] +fn unknown_semantics_collisions_and_output_reordering_fail_closed() { + let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut injected_cause = healthy_expected.clone(); + injected_cause["serverCause"] = json!("The DP triggered the client failure."); + if mutation_was_accepted("healthy-package", &healthy_manifest, &injected_cause) { + accepted.push("unknown server-cause field"); + } + + let mut reversed_observations = healthy_expected.clone(); + reversed_observations["transactions"][0]["observations"] + .as_array_mut() + .expect("observations are mutable") + .reverse(); + if mutation_was_accepted("healthy-package", &healthy_manifest, &reversed_observations) { + accepted.push("reversed observation output"); + } + + let mismatch_manifest = + read_json("content-version-mismatch", "manifest.json").expect("manifest loads"); + let mut mismatch_expected = + read_json("content-version-mismatch", "expected.json").expect("expected loads"); + mismatch_expected["transactions"] + .as_array_mut() + .expect("transactions are mutable") + .reverse(); + if mutation_was_accepted( + "content-version-mismatch", + &mismatch_manifest, + &mismatch_expected, + ) { + accepted.push("reversed transaction output"); + } + + let mut collided_manifest = healthy_manifest.clone(); + collided_manifest["artifacts"][1]["relativePath"] = + collided_manifest["artifacts"][0]["relativePath"].clone(); + collided_manifest["artifacts"][1]["originalBasename"] = + collided_manifest["artifacts"][0]["originalBasename"].clone(); + if mutation_was_accepted("healthy-package", &collided_manifest, &healthy_expected) { + accepted.push("colliding physical evidence destination"); + } + + assert!( + accepted.is_empty(), + "closed-schema/collision/order mutations were accepted: {accepted:?}" + ); +} 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..00c443f32 --- /dev/null +++ b/docs/sccm/preparation/issue-329-distribution-point-corpus.md @@ -0,0 +1,151 @@ +# 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` | exact DP handle | Receive and distribute | +| `server-dp-distribution` | `PkgXferMgr.log` | `siteServer` | exact DP handle | 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 + handle; +- source ID, exact basename, source grammar, synthetic version, path + fingerprint, and `SYNTHETIC://` provenance; +- rotation kind, lineage, and fragment completeness; +- 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` are separate subjects even though +the site-server basenames and package/content identifiers overlap. + +## 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`, limited to +`5.00.TEST.*` fixture evidence. It 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. + +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; 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. + +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, DP topology, terminal evidence, +coverage states, role provenance, causal fields, rotations, and transaction +cardinality. 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. From 9746f5e2ea634583d6f98ba6ca4f6936ee48fa96 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 22:28:12 -0400 Subject: [PATCH 047/422] test(sccm): close issue 325 exact-head gaps --- .../inventory-compliance-metering/README.md | 3 +- .../compliance/coverage-states/expected.json | 22 +- .../compliance/coverage-states/manifest.json | 76 +- .../expected.json | 30 +- .../manifest.json | 48 +- .../terminal-failures/expected.json | 4 +- .../inventory/coverage-states/expected.json | 22 +- .../inventory/coverage-states/manifest.json | 76 +- .../inventory/terminal-failures/expected.json | 8 +- .../metering/coverage-states/expected.json | 22 +- .../metering/coverage-states/manifest.json | 68 +- .../metering/rotation-boundary/expected.json | 8 +- .../metering/rotation-boundary/manifest.json | 28 +- .../metering/terminal-failures/expected.json | 4 +- ...ry_compliance_metering_fixture_contract.rs | 991 +++++++++++++++--- ...nt-inventory-compliance-metering-corpus.md | 37 +- 16 files changed, 1090 insertions(+), 357 deletions(-) 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 index 4a6e36c56..4b6de4dee 100644 --- 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 @@ -15,7 +15,8 @@ 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; + outcomes with cited evidence, closed non-causal schemas, evidence-backed phase + state, and canonical output ordering; - optional `evidence/`: raw CCM transport records or deliberately incomplete synthetic input. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/expected.json index b4b010467..2a52b36aa 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/expected.json @@ -16,10 +16,10 @@ "compliance-coverage-states-absent", "compliance-coverage-states-access-denied", "compliance-coverage-states-capped", - "compliance-coverage-states-skipped", - "compliance-coverage-states-unsupported", "compliance-coverage-states-malformed", - "compliance-coverage-states-partial" + "compliance-coverage-states-partial", + "compliance-coverage-states-skipped", + "compliance-coverage-states-unsupported" ], "confidenceCeiling": "low", "correlationEligible": false, @@ -43,24 +43,24 @@ "state": "capped" }, { - "artifactId": "compliance-coverage-states-skipped", + "artifactId": "compliance-coverage-states-malformed", "logicalArtifactId": "client-compliance", - "state": "skipped" + "state": "parseFailed" }, { - "artifactId": "compliance-coverage-states-unsupported", + "artifactId": "compliance-coverage-states-partial", "logicalArtifactId": "client-compliance", - "state": "unsupported" + "state": "partial" }, { - "artifactId": "compliance-coverage-states-malformed", + "artifactId": "compliance-coverage-states-skipped", "logicalArtifactId": "client-compliance", - "state": "parseFailed" + "state": "skipped" }, { - "artifactId": "compliance-coverage-states-partial", + "artifactId": "compliance-coverage-states-unsupported", "logicalArtifactId": "client-compliance", - "state": "partial" + "state": "unsupported" } ], "findings": [], diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json index bae10018a..950177edf 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json @@ -90,7 +90,7 @@ "truncated": true }, { - "artifactId": "compliance-coverage-states-skipped", + "artifactId": "compliance-coverage-states-malformed", "designOnlyCatalog": { "entryId": "client-compliance", "groupMemberships": [ @@ -99,21 +99,26 @@ }, "role": "client", "kind": "ccmLog", - "captureState": "skipped", - "originalBasename": "DCMReporting.log", - "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/DCMReporting.log", - "pathFingerprint": "synthetic-compliance-coverage-states-skipped-root-a", + "captureState": "parseFailed", + "originalBasename": "CIAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/CIAgent.log", + "pathFingerprint": "synthetic-compliance-coverage-states-malformed-root-a", "rotation": { "kind": "current", "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 0, - "relativePath": null + "bytesCopied": 42, + "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } }, { - "artifactId": "compliance-coverage-states-unsupported", + "artifactId": "compliance-coverage-states-partial", "designOnlyCatalog": { "entryId": "client-compliance", "groupMemberships": [ @@ -122,21 +127,26 @@ }, "role": "client", "kind": "ccmLog", - "captureState": "unsupported", - "originalBasename": "StateMessage.log", - "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/StateMessage.log", - "pathFingerprint": "synthetic-compliance-coverage-states-unsupported-root-a", + "captureState": "captured", + "originalBasename": "CITaskMgr.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/CITaskMgr.log", + "pathFingerprint": "synthetic-compliance-coverage-states-partial-root-a", "rotation": { "kind": "current", "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 0, - "relativePath": null + "bytesCopied": 60, + "relativePath": "evidence/client-compliance/root-a/current/CITaskMgr.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } }, { - "artifactId": "compliance-coverage-states-malformed", + "artifactId": "compliance-coverage-states-skipped", "designOnlyCatalog": { "entryId": "client-compliance", "groupMemberships": [ @@ -145,26 +155,21 @@ }, "role": "client", "kind": "ccmLog", - "captureState": "parseFailed", - "originalBasename": "CIAgent.log", - "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/CIAgent.log", - "pathFingerprint": "synthetic-compliance-coverage-states-malformed-root-a", + "captureState": "skipped", + "originalBasename": "DCMReporting.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/DCMReporting.log", + "pathFingerprint": "synthetic-compliance-coverage-states-skipped-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 - } + "bytesCopied": 0, + "relativePath": null }, { - "artifactId": "compliance-coverage-states-partial", + "artifactId": "compliance-coverage-states-unsupported", "designOnlyCatalog": { "entryId": "client-compliance", "groupMemberships": [ @@ -173,23 +178,18 @@ }, "role": "client", "kind": "ccmLog", - "captureState": "captured", - "originalBasename": "CITaskMgr.log", - "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/CITaskMgr.log", - "pathFingerprint": "synthetic-compliance-coverage-states-partial-root-a", + "captureState": "unsupported", + "originalBasename": "StateMessage.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/StateMessage.log", + "pathFingerprint": "synthetic-compliance-coverage-states-unsupported-root-a", "rotation": { "kind": "current", "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 60, - "relativePath": "evidence/client-compliance/root-a/current/CITaskMgr.log", - "encoding": "utf-8", - "collectionLimit": { - "byteLimit": 4096, - "limitApplied": false - } + "bytesCopied": 0, + "relativePath": null } ] } 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 index 4e14581e5..9a978c760 100644 --- 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 @@ -9,6 +9,16 @@ }, "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", @@ -28,31 +38,21 @@ "confidenceCeiling": "low", "correlationEligible": false, "claim": "Unknown source version has no selected extraction profile." - }, - { - "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." } ], "coverage": [ { - "artifactId": "compliance-malformed-unknown-profile-invalid-offset-malformed", + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-invalid-offset", "logicalArtifactId": "client-compliance", - "state": "parseFailed" + "state": "captured" }, { - "artifactId": "compliance-malformed-unknown-profile-invalid-offset-unknown-version", + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-malformed", "logicalArtifactId": "client-compliance", - "state": "captured" + "state": "parseFailed" }, { - "artifactId": "compliance-malformed-unknown-profile-invalid-offset-invalid-offset", + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-unknown-version", "logicalArtifactId": "client-compliance", "state": "captured" } 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 index cd4ce89b3..18fc92ccb 100644 --- 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 @@ -15,7 +15,7 @@ }, "artifacts": [ { - "artifactId": "compliance-malformed-unknown-profile-invalid-offset-malformed", + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-invalid-offset", "designOnlyCatalog": { "entryId": "client-compliance", "groupMemberships": [ @@ -24,18 +24,18 @@ }, "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", + "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": false + "fragmentComplete": true }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 42, - "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", + "bytesCopied": 647, + "relativePath": "evidence/client-compliance/root-a/current/DCMReporting.log", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, @@ -43,7 +43,7 @@ } }, { - "artifactId": "compliance-malformed-unknown-profile-invalid-offset-unknown-version", + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-malformed", "designOnlyCatalog": { "entryId": "client-compliance", "groupMemberships": [ @@ -52,18 +52,18 @@ }, "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", + "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": true + "fragmentComplete": false }, - "sourceVersion": "9.99.UNKNOWN", + "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 329, - "relativePath": "evidence/client-compliance/root-a/current/CITaskMgr.log", + "bytesCopied": 42, + "relativePath": "evidence/client-compliance/root-a/current/CIAgent.log", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, @@ -71,7 +71,7 @@ } }, { - "artifactId": "compliance-malformed-unknown-profile-invalid-offset-invalid-offset", + "artifactId": "compliance-malformed-unknown-profile-invalid-offset-unknown-version", "designOnlyCatalog": { "entryId": "client-compliance", "groupMemberships": [ @@ -81,17 +81,17 @@ "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", + "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": "5.00.TEST.325", + "sourceVersion": "9.99.UNKNOWN", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 647, - "relativePath": "evidence/client-compliance/root-a/current/DCMReporting.log", + "bytesCopied": 329, + "relativePath": "evidence/client-compliance/root-a/current/CITaskMgr.log", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, 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 index 3b14ff5bf..790adb9cc 100644 --- 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 @@ -55,7 +55,7 @@ "state": "failed", "classification": "confirmedFailure", "confidence": "high", - "lastSuccessfulPhase": "Evaluate", + "lastSuccessfulPhase": null, "evidence": [ { "artifactId": "compliance-terminal-failures-remediate-current", @@ -86,7 +86,7 @@ "state": "failed", "classification": "confirmedFailure", "confidence": "high", - "lastSuccessfulPhase": "Remediate", + "lastSuccessfulPhase": null, "evidence": [ { "artifactId": "compliance-terminal-failures-report-current", diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/expected.json index eebed8f91..a5ca687d3 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/expected.json @@ -16,10 +16,10 @@ "inventory-coverage-states-absent", "inventory-coverage-states-access-denied", "inventory-coverage-states-capped", - "inventory-coverage-states-skipped", - "inventory-coverage-states-unsupported", "inventory-coverage-states-malformed", - "inventory-coverage-states-partial" + "inventory-coverage-states-partial", + "inventory-coverage-states-skipped", + "inventory-coverage-states-unsupported" ], "confidenceCeiling": "low", "correlationEligible": false, @@ -43,24 +43,24 @@ "state": "capped" }, { - "artifactId": "inventory-coverage-states-skipped", + "artifactId": "inventory-coverage-states-malformed", "logicalArtifactId": "client-inventory", - "state": "skipped" + "state": "parseFailed" }, { - "artifactId": "inventory-coverage-states-unsupported", + "artifactId": "inventory-coverage-states-partial", "logicalArtifactId": "client-inventory", - "state": "unsupported" + "state": "partial" }, { - "artifactId": "inventory-coverage-states-malformed", + "artifactId": "inventory-coverage-states-skipped", "logicalArtifactId": "client-inventory", - "state": "parseFailed" + "state": "skipped" }, { - "artifactId": "inventory-coverage-states-partial", + "artifactId": "inventory-coverage-states-unsupported", "logicalArtifactId": "client-inventory", - "state": "partial" + "state": "unsupported" } ], "findings": [], diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json index 7e6a9cc9b..3df759d4a 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json @@ -90,7 +90,7 @@ "truncated": true }, { - "artifactId": "inventory-coverage-states-skipped", + "artifactId": "inventory-coverage-states-malformed", "designOnlyCatalog": { "entryId": "client-inventory", "groupMemberships": [ @@ -99,21 +99,26 @@ }, "role": "client", "kind": "ccmLog", - "captureState": "skipped", - "originalBasename": "InventoryAgent.log", - "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgent.log", - "pathFingerprint": "synthetic-inventory-coverage-states-skipped-root-a", + "captureState": "parseFailed", + "originalBasename": "InventoryAgentProvider.log", + "sanitizedSourcePath": "SYNTHETIC://root-f/CCM/Logs/InventoryAgentProvider.log", + "pathFingerprint": "synthetic-inventory-coverage-states-malformed-root-f", "rotation": { "kind": "current", "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 0, - "relativePath": null + "bytesCopied": 41, + "relativePath": "evidence/client-inventory/root-f/current/InventoryAgentProvider.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } }, { - "artifactId": "inventory-coverage-states-unsupported", + "artifactId": "inventory-coverage-states-partial", "designOnlyCatalog": { "entryId": "client-inventory", "groupMemberships": [ @@ -122,21 +127,26 @@ }, "role": "client", "kind": "ccmLog", - "captureState": "unsupported", - "originalBasename": "InventoryProvider.log", - "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryProvider.log", - "pathFingerprint": "synthetic-inventory-coverage-states-unsupported-root-a", + "captureState": "captured", + "originalBasename": "InventoryAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgent.log", + "pathFingerprint": "synthetic-inventory-coverage-states-partial-root-a", "rotation": { "kind": "current", "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 0, - "relativePath": null + "bytesCopied": 59, + "relativePath": "evidence/client-inventory/root-a/current/InventoryAgent.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } }, { - "artifactId": "inventory-coverage-states-malformed", + "artifactId": "inventory-coverage-states-skipped", "designOnlyCatalog": { "entryId": "client-inventory", "groupMemberships": [ @@ -145,26 +155,21 @@ }, "role": "client", "kind": "ccmLog", - "captureState": "parseFailed", - "originalBasename": "InventoryAgentProvider.log", - "sanitizedSourcePath": "SYNTHETIC://root-f/CCM/Logs/InventoryAgentProvider.log", - "pathFingerprint": "synthetic-inventory-coverage-states-malformed-root-f", + "captureState": "skipped", + "originalBasename": "InventoryAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgent.log", + "pathFingerprint": "synthetic-inventory-coverage-states-skipped-root-a", "rotation": { "kind": "current", "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 41, - "relativePath": "evidence/client-inventory/root-f/current/InventoryAgentProvider.log", - "encoding": "utf-8", - "collectionLimit": { - "byteLimit": 4096, - "limitApplied": false - } + "bytesCopied": 0, + "relativePath": null }, { - "artifactId": "inventory-coverage-states-partial", + "artifactId": "inventory-coverage-states-unsupported", "designOnlyCatalog": { "entryId": "client-inventory", "groupMemberships": [ @@ -173,23 +178,18 @@ }, "role": "client", "kind": "ccmLog", - "captureState": "captured", - "originalBasename": "InventoryAgent.log", - "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgent.log", - "pathFingerprint": "synthetic-inventory-coverage-states-partial-root-a", + "captureState": "unsupported", + "originalBasename": "InventoryProvider.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryProvider.log", + "pathFingerprint": "synthetic-inventory-coverage-states-unsupported-root-a", "rotation": { "kind": "current", "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 59, - "relativePath": "evidence/client-inventory/root-a/current/InventoryAgent.log", - "encoding": "utf-8", - "collectionLimit": { - "byteLimit": 4096, - "limitApplied": false - } + "bytesCopied": 0, + "relativePath": null } ] } 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 index fd3a474a6..36a886d45 100644 --- 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 @@ -53,7 +53,7 @@ "state": "failed", "classification": "confirmedFailure", "confidence": "high", - "lastSuccessfulPhase": "Collect", + "lastSuccessfulPhase": null, "evidence": [ { "artifactId": "inventory-terminal-failures-provider-current", @@ -83,7 +83,7 @@ "state": "failed", "classification": "confirmedFailure", "confidence": "high", - "lastSuccessfulPhase": "Provider", + "lastSuccessfulPhase": null, "evidence": [ { "artifactId": "inventory-terminal-failures-provider-current", @@ -113,7 +113,7 @@ "state": "failed", "classification": "confirmedFailure", "confidence": "high", - "lastSuccessfulPhase": "Serialize", + "lastSuccessfulPhase": null, "evidence": [ { "artifactId": "inventory-terminal-failures-report-current", @@ -143,7 +143,7 @@ "state": "failed", "classification": "confirmedFailure", "confidence": "high", - "lastSuccessfulPhase": "Queue", + "lastSuccessfulPhase": null, "evidence": [ { "artifactId": "inventory-terminal-failures-report-current", diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/expected.json index 2cd6145db..c47d453a0 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/expected.json @@ -16,10 +16,10 @@ "metering-coverage-states-absent", "metering-coverage-states-access-denied", "metering-coverage-states-capped", - "metering-coverage-states-skipped", - "metering-coverage-states-unsupported", "metering-coverage-states-malformed", - "metering-coverage-states-partial" + "metering-coverage-states-partial", + "metering-coverage-states-skipped", + "metering-coverage-states-unsupported" ], "confidenceCeiling": "low", "correlationEligible": false, @@ -43,24 +43,24 @@ "state": "capped" }, { - "artifactId": "metering-coverage-states-skipped", + "artifactId": "metering-coverage-states-malformed", "logicalArtifactId": "client-metering", - "state": "skipped" + "state": "parseFailed" }, { - "artifactId": "metering-coverage-states-unsupported", + "artifactId": "metering-coverage-states-partial", "logicalArtifactId": "client-metering", - "state": "unsupported" + "state": "partial" }, { - "artifactId": "metering-coverage-states-malformed", + "artifactId": "metering-coverage-states-skipped", "logicalArtifactId": "client-metering", - "state": "parseFailed" + "state": "skipped" }, { - "artifactId": "metering-coverage-states-partial", + "artifactId": "metering-coverage-states-unsupported", "logicalArtifactId": "client-metering", - "state": "partial" + "state": "unsupported" } ], "findings": [], diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/manifest.json index c205c7779..9734af2b5 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/manifest.json @@ -90,7 +90,7 @@ "truncated": true }, { - "artifactId": "metering-coverage-states-skipped", + "artifactId": "metering-coverage-states-malformed", "designOnlyCatalog": { "entryId": "client-metering", "groupMemberships": [ @@ -99,21 +99,26 @@ }, "role": "client", "kind": "ccmLog", - "captureState": "skipped", + "captureState": "parseFailed", "originalBasename": "SWMTRReportGen.log", - "sanitizedSourcePath": "SYNTHETIC://root-d/CCM/Logs/SWMTRReportGen.log", - "pathFingerprint": "synthetic-metering-coverage-states-skipped-root-d", + "sanitizedSourcePath": "SYNTHETIC://root-f/CCM/Logs/SWMTRReportGen.log", + "pathFingerprint": "synthetic-metering-coverage-states-malformed-root-f", "rotation": { "kind": "current", "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 0, - "relativePath": null + "bytesCopied": 40, + "relativePath": "evidence/client-metering/root-f/current/SWMTRReportGen.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } }, { - "artifactId": "metering-coverage-states-unsupported", + "artifactId": "metering-coverage-states-partial", "designOnlyCatalog": { "entryId": "client-metering", "groupMemberships": [ @@ -122,21 +127,26 @@ }, "role": "client", "kind": "ccmLog", - "captureState": "unsupported", + "captureState": "captured", "originalBasename": "SWMTRReportGen.log", - "sanitizedSourcePath": "SYNTHETIC://root-e/CCM/Logs/SWMTRReportGen.log", - "pathFingerprint": "synthetic-metering-coverage-states-unsupported-root-e", + "sanitizedSourcePath": "SYNTHETIC://root-g/CCM/Logs/SWMTRReportGen.log", + "pathFingerprint": "synthetic-metering-coverage-states-partial-root-g", "rotation": { "kind": "current", "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 0, - "relativePath": null + "bytesCopied": 58, + "relativePath": "evidence/client-metering/root-g/current/SWMTRReportGen.log", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + } }, { - "artifactId": "metering-coverage-states-malformed", + "artifactId": "metering-coverage-states-skipped", "designOnlyCatalog": { "entryId": "client-metering", "groupMemberships": [ @@ -145,26 +155,21 @@ }, "role": "client", "kind": "ccmLog", - "captureState": "parseFailed", + "captureState": "skipped", "originalBasename": "SWMTRReportGen.log", - "sanitizedSourcePath": "SYNTHETIC://root-f/CCM/Logs/SWMTRReportGen.log", - "pathFingerprint": "synthetic-metering-coverage-states-malformed-root-f", + "sanitizedSourcePath": "SYNTHETIC://root-d/CCM/Logs/SWMTRReportGen.log", + "pathFingerprint": "synthetic-metering-coverage-states-skipped-root-d", "rotation": { "kind": "current", "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 40, - "relativePath": "evidence/client-metering/root-f/current/SWMTRReportGen.log", - "encoding": "utf-8", - "collectionLimit": { - "byteLimit": 4096, - "limitApplied": false - } + "bytesCopied": 0, + "relativePath": null }, { - "artifactId": "metering-coverage-states-partial", + "artifactId": "metering-coverage-states-unsupported", "designOnlyCatalog": { "entryId": "client-metering", "groupMemberships": [ @@ -173,23 +178,18 @@ }, "role": "client", "kind": "ccmLog", - "captureState": "captured", + "captureState": "unsupported", "originalBasename": "SWMTRReportGen.log", - "sanitizedSourcePath": "SYNTHETIC://root-g/CCM/Logs/SWMTRReportGen.log", - "pathFingerprint": "synthetic-metering-coverage-states-partial-root-g", + "sanitizedSourcePath": "SYNTHETIC://root-e/CCM/Logs/SWMTRReportGen.log", + "pathFingerprint": "synthetic-metering-coverage-states-unsupported-root-e", "rotation": { "kind": "current", "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 58, - "relativePath": "evidence/client-metering/root-g/current/SWMTRReportGen.log", - "encoding": "utf-8", - "collectionLimit": { - "byteLimit": 4096, - "limitApplied": false - } + "bytesCopied": 0, + "relativePath": null } ] } 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 index e9b382aee..c7ac30741 100644 --- 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 @@ -13,8 +13,8 @@ "observationId": "metering-rotation-split", "kind": "rotationSplit", "artifactIds": [ - "metering-rotation-boundary-report-lo", - "metering-rotation-boundary-report-current" + "metering-rotation-boundary-report-current", + "metering-rotation-boundary-report-lo" ], "confidenceCeiling": "low", "correlationEligible": false, @@ -23,12 +23,12 @@ ], "coverage": [ { - "artifactId": "metering-rotation-boundary-report-lo", + "artifactId": "metering-rotation-boundary-report-current", "logicalArtifactId": "client-metering", "state": "partial" }, { - "artifactId": "metering-rotation-boundary-report-current", + "artifactId": "metering-rotation-boundary-report-lo", "logicalArtifactId": "client-metering", "state": "partial" } 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 index d18b27828..257978af3 100644 --- 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 @@ -15,7 +15,7 @@ }, "artifacts": [ { - "artifactId": "metering-rotation-boundary-report-lo", + "artifactId": "metering-rotation-boundary-report-current", "designOnlyCatalog": { "entryId": "client-metering", "groupMemberships": [ @@ -25,17 +25,17 @@ "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", + "originalBasename": "SWMTRReportGen.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/SWMTRReportGen.log", + "pathFingerprint": "synthetic-metering-rotation-boundary-report-current-root-a", "rotation": { - "kind": "lo", + "kind": "current", "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", + "bytesCopied": 323, + "relativePath": "evidence/client-metering/root-a/current/SWMTRReportGen.log", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, @@ -43,7 +43,7 @@ } }, { - "artifactId": "metering-rotation-boundary-report-current", + "artifactId": "metering-rotation-boundary-report-lo", "designOnlyCatalog": { "entryId": "client-metering", "groupMemberships": [ @@ -53,17 +53,17 @@ "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", + "originalBasename": "SWMTRReportGen.log.lo", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/SWMTRReportGen.log.lo", + "pathFingerprint": "synthetic-metering-rotation-boundary-report-lo-root-a", "rotation": { - "kind": "current", + "kind": "lo", "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 323, - "relativePath": "evidence/client-metering/root-a/current/SWMTRReportGen.log", + "bytesCopied": 327, + "relativePath": "evidence/client-metering/root-a/lo/SWMTRReportGen.log.lo", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, 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 index 415ec4723..d9f5cd10c 100644 --- 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 @@ -55,7 +55,7 @@ "state": "failed", "classification": "confirmedFailure", "confidence": "high", - "lastSuccessfulPhase": "Collect", + "lastSuccessfulPhase": null, "evidence": [ { "artifactId": "metering-terminal-failures-report-current", @@ -86,7 +86,7 @@ "state": "failed", "classification": "confirmedFailure", "confidence": "high", - "lastSuccessfulPhase": "Aggregate", + "lastSuccessfulPhase": null, "evidence": [ { "artifactId": "metering-terminal-failures-report-current", 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 index c477177fd..a2dd8b16d 100644 --- 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 @@ -279,6 +279,67 @@ fn required_string<'a>(value: &'a Value, field: &str, context: &str) -> Result<& .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 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)? { @@ -379,6 +440,52 @@ fn rewrite_artifact_evidence( 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)], @@ -522,27 +629,6 @@ fn expected_next_artifact( }))) } -fn expected_last_successful_phase( - family: &str, - phase: &str, - classification: &str, -) -> Result, String> { - let phases = admitted_phases(family)?; - let phase_index = phases - .iter() - .position(|candidate| *candidate == phase) - .ok_or_else(|| format!("{family} phase {phase} is not admitted"))?; - - match classification { - "confirmedFailure" => Ok(phase_index.checked_sub(1).map(|index| phases[index])), - "success" | "recovery" | "evaluationResult" => Ok(Some(phases[phase_index])), - "symptom" => Ok(None), - other => Err(format!( - "{family}/{phase} has unsupported last-success classification {other}" - )), - } -} - fn additive_artifact(artifact: &Value) -> Result { let artifact_id = required_string(artifact, "artifactId", "artifact")?; let rotation = match required_string(&artifact["rotation"], "kind", artifact_id)? { @@ -569,11 +655,89 @@ fn additive_artifact(artifact: &Value) -> Result { } struct CitedEvidenceRecord { - raw_record: String, + 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 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, "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, @@ -644,8 +808,10 @@ fn evidence_record_texts( )); } 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)?; records.push(CitedEvidenceRecord { - raw_record: (*line).to_owned(), + fields, source_version: source_version.to_owned(), timestamp, }); @@ -654,29 +820,6 @@ fn evidence_record_texts( Ok(records) } -fn record_exact_token_values<'a>(record: &'a str, field: &str) -> Vec<&'a str> { - const MESSAGE_PREFIX: &str = ""; - let Some(message_start) = record.find(MESSAGE_PREFIX) else { - return Vec::new(); - }; - let payload_start = message_start + MESSAGE_PREFIX.len(); - let Some(message_end) = record[payload_start..].find(MESSAGE_SUFFIX) else { - return Vec::new(); - }; - record[payload_start..payload_start + message_end] - .split_ascii_whitespace() - .filter_map(|token| { - let (name, value) = token.split_once('=')?; - (name == field).then_some(value) - }) - .collect() -} - -fn record_contains_exact_key_pair(record: &str, field: &str, value: &str) -> bool { - record_exact_token_values(record, field).contains(&value) -} - fn validate_contract( family: &str, scenario: &str, @@ -689,6 +832,27 @@ fn validate_contract( let phases = admitted_phases(family)?; let profile = expected_profile(family)?; + require_exact_object_fields( + expected, + &[ + "contractState", + "scenario", + "workflow", + "extractionProfile", + "transactions", + "sourceLocalObservations", + "coverage", + "findings", + "prohibitedClaims", + ], + "expected", + )?; + require_exact_object_fields( + &expected["extractionProfile"], + &["id", "selectionState", "versionPrefix"], + "extractionProfile", + )?; + if manifest["sccmManifestVersion"] != 1 || manifest["contractState"] != "proposedPending318And319" || manifest["proposalOnly"] != true @@ -715,8 +879,10 @@ fn validate_contract( 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 sanitized_paths = BTreeSet::new(); let mut path_fingerprints = BTreeSet::new(); let mut referenced_files = BTreeSet::new(); let mut expected_coverage = BTreeMap::new(); @@ -837,6 +1003,13 @@ fn validate_contract( if !sanitized_path.starts_with("SYNTHETIC://") || !sanitized_path.ends_with(basename) { return Err(format!("{artifact_id} source path is not sanitized")); } + if scenario == "same-minute-collision" + && !sanitized_paths.insert(sanitized_path.to_owned()) + { + return Err(format!( + "{artifact_id} has an aliased sanitized source path" + )); + } let fingerprint = required_string(artifact, "pathFingerprint", artifact_id)?; if fingerprint.is_empty() || !path_fingerprints.insert(fingerprint.to_owned()) { return Err(format!( @@ -888,6 +1061,13 @@ fn validate_contract( "{artifact_id} attempted source path is unsanitized" )); } + if scenario == "same-minute-collision" + && !sanitized_paths.insert(sanitized_path.to_owned()) + { + return Err(format!( + "{artifact_id} has an aliased sanitized source path" + )); + } let fingerprint = required_string(artifact, "pathFingerprint", artifact_id)?; if fingerprint.is_empty() || !path_fingerprints.insert(fingerprint.to_owned()) { return Err(format!( @@ -943,8 +1123,14 @@ fn validate_contract( 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")); @@ -972,18 +1158,39 @@ fn validate_contract( let prohibited_claims = expected["prohibitedClaims"] .as_array() .ok_or_else(|| "prohibitedClaims is not an array".to_owned())?; - if prohibited_claims.len() != 4 { - return Err("prohibitedClaims does not cover all four safety boundaries".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 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")?; if !observation_ids.insert(observation_id.to_owned()) { return Err(format!("duplicate observationId {observation_id}")); @@ -1006,28 +1213,8 @@ fn validate_contract( 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 causal_word = lower_claim - .split(|character: char| !character.is_ascii_alphanumeric()) - .any(|word| { - matches!( - word, - "cause" - | "caused" - | "causes" - | "causing" - | "causal" - | "because" - | "proves" - | "proof" - ) - }); - if causal_word - || ["root cause", "resulted in", "led to", "responsible for"] - .iter() - .any(|phrase| lower_claim.contains(phrase)) - { - return Err(format!("{observation_id} makes a causal/proof claim")); + if claim != expected_observation_claim(family, kind)? { + return Err(format!("{observation_id} claim is not canonical")); } let artifact_ids = observation["artifactIds"] .as_array() @@ -1037,15 +1224,29 @@ fn validate_contract( "{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(); 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)?); observed_artifact_ids.insert(artifact_id.to_owned()); if kind == "unknownProfile" { unknown_profile_observations.insert(artifact_id.to_owned()); @@ -1054,6 +1255,31 @@ fn validate_contract( invalid_offset_observations.insert(artifact_id.to_owned()); } } + let incompatible = match kind { + "coverageGap" => observed_states.iter().any(|state| state == "captured"), + "rotationSplit" => { + observed_states.len() < 2 + || observed_states.iter().any(|state| state != "partial") + || observed_rotations != ["current", "lo"].into_iter().collect::>() + } + "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) { @@ -1077,8 +1303,27 @@ fn validate_contract( .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 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")?; if !transaction_ids.insert(transaction_id.to_owned()) { return Err(format!("duplicate transactionId {transaction_id}")); @@ -1118,6 +1363,19 @@ fn validate_contract( 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) { @@ -1125,6 +1383,10 @@ fn validate_contract( "{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) { @@ -1140,17 +1402,71 @@ fn validate_contract( } 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")); + } + 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_contains_exact_key_pair(&record.raw_record, field, value) { + if !record_field_is(record, field, value) { return Err(format!( "{transaction_id} {field} is not co-located in every cited CCM record" )); @@ -1159,7 +1475,7 @@ fn validate_contract( } if !records .iter() - .any(|record| record_contains_exact_key_pair(&record.raw_record, "Phase", phase)) + .any(|record| record_field_is(record, "Phase", phase)) { return Err(format!( "{transaction_id} phase {phase} is not bound to cited evidence" @@ -1177,39 +1493,20 @@ fn validate_contract( } let state = required_string(transaction, "state", transaction_id)?; let classification = required_string(transaction, "classification", transaction_id)?; - let expected_last_successful_phase = - expected_last_successful_phase(family, phase, classification)?; - if last_successful_phase != expected_last_successful_phase { - return Err(format!( - "{transaction_id} lastSuccessfulPhase {last_successful_phase:?} != required {expected_last_successful_phase:?}" - )); - } let has_phase_record = |disposition: &str, terminal: bool| { records.iter().any(|record| { - record_contains_exact_key_pair(&record.raw_record, "Phase", phase) - && record_contains_exact_key_pair( - &record.raw_record, - "Disposition", - disposition, - ) - && record_contains_exact_key_pair( - &record.raw_record, - "Terminal", - if terminal { "true" } else { "false" }, - ) + 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_contains_exact_key_pair(&record.raw_record, "Phase", phase) - && record_contains_exact_key_pair(&record.raw_record, "Terminal", "true") - }) - .flat_map(|record| { - record_exact_token_values(&record.raw_record, "Disposition") - .into_iter() - .map(str::to_owned) + 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!( @@ -1249,17 +1546,15 @@ fn validate_contract( if family != "compliance" || phase != "Evaluate" || confidence != "high" - || !has_phase_record(disposition, true) || !records.iter().any(|record| { - record_contains_exact_key_pair( - &record.raw_record, - "ResultType", - "Evaluation", - ) + 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 contract is invalid" + "{transaction_id} compliance evaluation result is not source-record-local" )); } } @@ -1276,17 +1571,9 @@ fn validate_contract( let latest_failure = records .iter() .filter(|record| { - record_contains_exact_key_pair(&record.raw_record, "Phase", phase) - && record_contains_exact_key_pair( - &record.raw_record, - "Disposition", - "Failed", - ) - && record_contains_exact_key_pair( - &record.raw_record, - "Terminal", - "true", - ) + record_field_is(record, "Phase", phase) + && record_field_is(record, "Disposition", "Failed") + && record_field_is(record, "Terminal", "true") }) .map(|record| { record @@ -1299,17 +1586,9 @@ fn validate_contract( let earliest_success = records .iter() .filter(|record| { - record_contains_exact_key_pair(&record.raw_record, "Phase", phase) - && record_contains_exact_key_pair( - &record.raw_record, - "Disposition", - "Succeeded", - ) - && record_contains_exact_key_pair( - &record.raw_record, - "Terminal", - "true", - ) + record_field_is(record, "Phase", phase) + && record_field_is(record, "Disposition", "Succeeded") + && record_field_is(record, "Terminal", "true") }) .map(|record| { record @@ -1346,14 +1625,28 @@ fn validate_contract( )); } } + 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}"))?; @@ -1872,7 +2165,7 @@ fn independent_review_blocker_invalid_additive_timestamp_provenance_is_rejected( "artifactIds": [artifact_id], "confidenceCeiling": "low", "correlationEligible": false, - "claim": "Invalid source offset cannot support ordered recovery." + "claim": "Invalid timestamp offset cannot support ordered or high-confidence workflow claims." })); assert_rejected_with( "recovery with invalid additive offset", @@ -1885,6 +2178,423 @@ fn independent_review_blocker_invalid_additive_timestamp_provenance_is_rejected( ); } +#[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, + "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, + "aliased sanitized source path", + ); +} + +#[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"); @@ -1950,6 +2660,11 @@ fn dynamic_evidence_mutations_cannot_fabricate_exact_or_high_confidence_facts() #[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"), @@ -1957,17 +2672,21 @@ fn exact_message_tokens_reject_key_and_semantic_lookalikes() { ("Terminal", "true"), ("ResultType", "Evaluation"), ] { - let exact = format!(""); - assert!(record_contains_exact_key_pair(&exact, field, value)); + 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 [ - format!(""), - format!(""), - format!(""), - format!(""), + 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!( - !record_contains_exact_key_pair(&lookalike, field, value), + fields.get(field).is_none_or(|actual| actual != value), "look-alike {field} token was accepted: {lookalike}" ); } @@ -2024,7 +2743,7 @@ fn dynamic_recovery_mutations_require_selected_profile_and_usable_offset() { "artifactIds": [unknown_artifact_id], "confidenceCeiling": "low", "correlationEligible": false, - "claim": "Unknown source version cannot support ordered recovery." + "claim": "Unknown source version has no selected extraction profile." })); assert_rejected( "medium recovery from unknown source profile", @@ -2196,7 +2915,7 @@ fn review_blocker_source_local_observations_reject_causal_language() { &scenario_root, &manifest, &expected, - "causal", + "claim is not canonical", ); } 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 index ca74dd5ba..d6facfabc 100644 --- a/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md +++ b/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md @@ -25,10 +25,12 @@ 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 in one -complete cited CCM logical record. A field borrowed from another line, artifact, -root, rotation, or workflow cannot complete a key. The profile identifiers in -this corpus are deliberately test-only: +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 profile identifiers in this corpus are deliberately test-only: - `sccm-client-inventory-5.00.test-v1` - `sccm-client-compliance-5.00.test-v1` @@ -87,19 +89,21 @@ 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 workflow and one versioned exact-key - profile; -- every evidence reference names a manifest artifact and valid line range; +- every transaction is bound to one unique workflow/profile/exact-key identity; +- every evidence reference names a manifest artifact and valid line range, and + 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` follows the declared family phase order, and a - confirmed failure requests exactly the bounded next artifact for that phase; +- `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 have a low confidence ceiling and are not - correlation eligible; +- source-local observations use a closed kind/artifact/claim schema, have a low + confidence ceiling, and are not correlation eligible; - next-artifact requests name one admitted logical group and basename, never an arbitrary path, drive, volume, wildcard, or recursive scan. @@ -130,6 +134,9 @@ of: - 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 structured fields, nested CCM envelopes, and compliance + result types borrowed from another source record; +- 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; @@ -140,10 +147,16 @@ of: 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; -- same-minute key borrowing between distinct root artifacts; +- 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. This mutation layer is independent of the positive fixture assertions, so an From 5e43b6104c537711637737ed96a89f5cc06fde34 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 22:31:07 -0400 Subject: [PATCH 048/422] test(sccm): harden distribution point corpus --- .../sccm/server/distribution_point/README.md | 4 + .../site/current/PkgXferMgr.log | 1 + .../site/current/distmgr.log | 2 + .../site/dp-02/current/PkgXferMgr.log | 1 - .../site/dp-02/current/distmgr.log | 2 - .../content-version-mismatch/expected.json | 22 +- .../content-version-mismatch/manifest.json | 50 +--- ...ver_distribution_point_fixture_contract.rs | 271 ++++++++++++++++-- .../issue-329-distribution-point-corpus.md | 18 +- 9 files changed, 278 insertions(+), 93 deletions(-) delete mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/dp-02/current/PkgXferMgr.log delete mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/dp-02/current/distmgr.log 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 index 1ef11fb2e..088756cce 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/README.md +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/README.md @@ -2,6 +2,10 @@ This directory is test-only input for Issue `#329`. +Site-server logs are physical captures, not per-DP projections. The multi-DP +scenario therefore stores `distmgr.log` and `PkgXferMgr.log` once and binds +exact DP/content/version identity from each normalized logical CCM record. + - Every evidence file is authored synthetic CCM text and contains the literal `SYNTHETIC FIXTURE` marker. - `manifest.json` records physical producer, workflow subject, coverage, 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 index df2c54a87..4557e6487 100644 --- 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 @@ -1,2 +1,3 @@ + 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 index 4089684ff..24a75c73e 100644 --- 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 @@ -1,4 +1,6 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/dp-02/current/PkgXferMgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/dp-02/current/PkgXferMgr.log deleted file mode 100644 index 1ec58d395..000000000 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/dp-02/current/PkgXferMgr.log +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/dp-02/current/distmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/dp-02/current/distmgr.log deleted file mode 100644 index 72c42e73a..000000000 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site/dp-02/current/distmgr.log +++ /dev/null @@ -1,2 +0,0 @@ - - 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 index a37868c5b..5d690be0b 100644 --- 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 @@ -10,9 +10,7 @@ {"artifactId": "dp-version-01-distmgr", "state": "captured"}, {"artifactId": "dp-version-02-pkgxfer", "state": "captured"}, {"artifactId": "dp-version-03-provider", "state": "captured"}, - {"artifactId": "dp-version-04-distmgr-dp02", "state": "captured"}, - {"artifactId": "dp-version-05-pkgxfer-dp02", "state": "captured"}, - {"artifactId": "dp-version-06-provider-dp02", "state": "captured"} + {"artifactId": "dp-version-04-provider-dp02", "state": "captured"} ], "transactions": [ { @@ -49,12 +47,12 @@ "nextSourceId": null, "coverageGapArtifactIds": [], "observations": [ - {"observationId": "01-dp02-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-04-distmgr-dp02", "startLine": 1, "endLine": 1}]}, - {"observationId": "02-dp02-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-04-distmgr-dp02", "startLine": 2, "endLine": 2}]}, - {"observationId": "03-dp02-transfer", "phase": "transfer", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-05-pkgxfer-dp02", "startLine": 1, "endLine": 1}]}, - {"observationId": "04-dp02-validate", "phase": "validate", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-06-provider-dp02", "startLine": 1, "endLine": 1}]}, - {"observationId": "05-dp02-available", "phase": "makeAvailable", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-06-provider-dp02", "startLine": 2, "endLine": 2}]}, - {"observationId": "06-dp02-report", "phase": "serveOrReport", "disposition": "succeeded", "terminal": true, "evidence": [{"artifactId": "dp-version-06-provider-dp02", "startLine": 3, "endLine": 3}]} + {"observationId": "01-dp02-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 3, "endLine": 3}]}, + {"observationId": "02-dp02-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 4, "endLine": 4}]}, + {"observationId": "03-dp02-transfer", "phase": "transfer", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-02-pkgxfer", "startLine": 2, "endLine": 2}]}, + {"observationId": "04-dp02-validate", "phase": "validate", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-04-provider-dp02", "startLine": 1, "endLine": 1}]}, + {"observationId": "05-dp02-available", "phase": "makeAvailable", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-04-provider-dp02", "startLine": 2, "endLine": 2}]}, + {"observationId": "06-dp02-report", "phase": "serveOrReport", "disposition": "succeeded", "terminal": true, "evidence": [{"artifactId": "dp-version-04-provider-dp02", "startLine": 3, "endLine": 3}]} ] }, { @@ -70,9 +68,9 @@ "nextSourceId": null, "coverageGapArtifactIds": [], "observations": [ - {"observationId": "01-v2-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 3, "endLine": 3}]}, - {"observationId": "02-v2-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 4, "endLine": 4}]}, - {"observationId": "03-v2-transfer-retry", "phase": "transfer", "disposition": "retrying", "terminal": false, "evidence": [{"artifactId": "dp-version-02-pkgxfer", "startLine": 2, "endLine": 2}]} + {"observationId": "01-v2-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 5, "endLine": 5}]}, + {"observationId": "02-v2-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 6, "endLine": 6}]}, + {"observationId": "03-v2-transfer-retry", "phase": "transfer", "disposition": "retrying", "terminal": false, "evidence": [{"artifactId": "dp-version-02-pkgxfer", "startLine": 3, "endLine": 3}]} ] } ], 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 index 278ef27b9..6b8af19f0 100644 --- 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 @@ -16,7 +16,7 @@ "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", @@ -27,7 +27,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 1404, + "bytesCopied": 2106, "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" }, { @@ -36,7 +36,7 @@ "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", @@ -47,7 +47,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 705, + "bytesCopied": 1058, "relativePath": "evidence/server-dp-distribution/site/current/PkgXferMgr.log" }, { @@ -71,47 +71,7 @@ "relativePath": "evidence/server-dp-distribution/dp/current/SMSDPProv.log" }, { - "artifactId": "dp-version-04-distmgr-dp02", - "sourceId": "server-dp-distribution", - "producerRole": "siteServer", - "producerHostHandle": "safe:server:lab-pri-01", - "workflowSubjectRole": "distributionPoint", - "workflowSubjectHandle": "safe:dp:lab-dp-02", - "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": 702, - "relativePath": "evidence/server-dp-distribution/site/dp-02/current/distmgr.log" - }, - { - "artifactId": "dp-version-05-pkgxfer-dp02", - "sourceId": "server-dp-distribution", - "producerRole": "siteServer", - "producerHostHandle": "safe:server:lab-pri-01", - "workflowSubjectRole": "distributionPoint", - "workflowSubjectHandle": "safe:dp:lab-dp-02", - "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": 353, - "relativePath": "evidence/server-dp-distribution/site/dp-02/current/PkgXferMgr.log" - }, - { - "artifactId": "dp-version-06-provider-dp02", + "artifactId": "dp-version-04-provider-dp02", "sourceId": "server-dp-distribution", "producerRole": "distributionPoint", "producerHostHandle": "safe:dp:lab-dp-02", diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index 58cb4752e..3a5a2890a 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -172,6 +172,9 @@ struct ParsedArtifact { source_id: String, role: String, basename: String, + workflow_subject_handle: Option, + rotation_kind: String, + rotation_lineage: String, fragment_complete: Option, } @@ -365,7 +368,7 @@ fn validate_manifest( let mut parsed_artifacts = BTreeMap::new(); let mut evidence_by_reference = BTreeMap::new(); let mut relative_paths = BTreeSet::new(); - let mut path_fingerprints = BTreeSet::new(); + let mut physical_source_identities = BTreeSet::new(); for artifact in artifacts { let artifact_id = match required_string(artifact, "artifactId", "artifact") { Ok(value) => value, @@ -412,6 +415,7 @@ fn validate_manifest( "producerHostHandle", "workflowSubjectRole", "workflowSubjectHandle", + "workflowSubjectBasis", "sourceKind", "originalBasename", "sanitizedSourcePath", @@ -447,10 +451,16 @@ fn validate_manifest( "{artifact_id} has an uncatalogued source/producer/basename combination" )); } + let workflow_subject_handle = artifact["workflowSubjectHandle"].as_str(); + let workflow_subject_basis = artifact["workflowSubjectBasis"].as_str(); if artifact["workflowSubjectRole"] != "distributionPoint" - || !artifact["workflowSubjectHandle"] - .as_str() - .is_some_and(|handle| distribution_point_handles.contains(handle)) + || match (workflow_subject_handle, workflow_subject_basis) { + (Some(handle), None) => !distribution_point_handles.contains(handle), + (None, Some("manifestTopology")) => { + role != "siteServer" || distribution_point_handles.len() < 2 + } + _ => true, + } { failures.push(format!( "{artifact_id} loses the distribution-point workflow subject" @@ -463,28 +473,47 @@ fn validate_manifest( failures.push(format!("{artifact_id} lacks an opaque producer handle")); } if role == "distributionPoint" - && artifact["producerHostHandle"] != artifact["workflowSubjectHandle"] + && (workflow_subject_handle.is_none() + || artifact["producerHostHandle"] != artifact["workflowSubjectHandle"]) { failures.push(format!( "{artifact_id} DP producer does not match its exact workflow subject" )); } - if !artifact["pathFingerprint"] - .as_str() - .is_some_and(|value| value.starts_with("synthetic:")) - || !artifact["sanitizedSourcePath"] - .as_str() - .is_some_and(|value| value.starts_with("SYNTHETIC://")) + let path_fingerprint = artifact["pathFingerprint"].as_str(); + let sanitized_source_path = artifact["sanitizedSourcePath"].as_str(); + if !path_fingerprint.is_some_and(|value| value.starts_with("synthetic:")) + || !sanitized_source_path.is_some_and(|value| value.starts_with("SYNTHETIC://")) { failures.push(format!("{artifact_id} leaks or omits path provenance")); } - if artifact["pathFingerprint"] + let rotation_kind = artifact["rotation"]["kind"].as_str(); + let rotation_value = artifact["rotation"]["value"] .as_str() - .is_some_and(|value| !path_fingerprints.insert(value.to_owned())) - { - failures.push(format!( - "{artifact_id} collapses a path fingerprint collision" - )); + .map(str::to_owned) + .or_else(|| { + artifact["rotation"]["value"] + .as_u64() + .map(|value| value.to_string()) + }) + .unwrap_or_default(); + if let (Some(producer), Some(source_path), Some(rotation_kind)) = ( + artifact["producerHostHandle"].as_str(), + sanitized_source_path, + rotation_kind, + ) { + let physical_identity = ( + producer.to_owned(), + source_path.to_owned(), + basename.to_owned(), + rotation_kind.to_owned(), + rotation_value, + ); + if !physical_source_identities.insert(physical_identity) { + failures.push(format!( + "{artifact_id} duplicates one physical source for another workflow subject" + )); + } } if artifact["sourceKind"] != "ccmLog" || !artifact["sourceVersion"] @@ -640,8 +669,22 @@ fn validate_manifest( "{artifact_id} loses distinct CCM code-origin provenance" )); } - if let Err(error) = parse_fixture_fields(&record.message) { - failures.push(format!("{artifact_id}: {error}")); + match parse_fixture_fields(&record.message) { + Ok(fields) => { + let record_dp_handle = fields.get("DpHandle").map(String::as_str); + if workflow_subject_handle + .is_some_and(|handle| record_dp_handle != Some(handle)) + || workflow_subject_basis == Some("manifestTopology") + && record_dp_handle.is_none_or(|handle| { + !distribution_point_handles.contains(handle) + }) + { + failures.push(format!( + "{artifact_id} record escapes its declared workflow-subject scope" + )); + } + } + 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")); @@ -675,6 +718,15 @@ fn validate_manifest( source_id: source_id.to_owned(), role: role.to_owned(), basename: basename.to_owned(), + workflow_subject_handle: workflow_subject_handle.map(str::to_owned), + rotation_kind: artifact["rotation"]["kind"] + .as_str() + .unwrap_or_default() + .to_owned(), + rotation_lineage: artifact["rotation"]["lineageId"] + .as_str() + .unwrap_or_default() + .to_owned(), fragment_complete: artifact["rotation"]["fragmentComplete"].as_bool(), }, ) @@ -1055,7 +1107,11 @@ fn validate_expected( match parsed.artifacts.get(cited_artifact_id) { Some(artifact) if artifact.role != "client" - && phase_allowed_for_artifact(artifact, phase) => {} + && phase_allowed_for_artifact(artifact, phase) + && artifact + .workflow_subject_handle + .as_deref() + .is_none_or(|handle| handle == key_fields["DpHandle"]) => {} _ => failures.push(format!( "{observation_id} cites an artifact that cannot own phase {phase}" )), @@ -1224,8 +1280,9 @@ fn validate_expected( observation_id, &mut failures, ); + let classification = observation["classification"].as_str(); if !matches!( - observation["classification"].as_str(), + classification, Some("ignoredClientEvidence" | "rotationSplit" | "malformedEvidence") ) || observation.get("key").is_some() || observation["correlationEligible"] != false @@ -1242,33 +1299,103 @@ fn validate_expected( .unwrap_or_default(); let mut sorted_artifact_ids = artifact_ids.clone(); sorted_artifact_ids.sort_unstable(); - if artifact_ids.is_empty() || artifact_ids != sorted_artifact_ids { + let unique_artifact_ids = artifact_ids.iter().copied().collect::>(); + if artifact_ids.is_empty() + || artifact_ids != sorted_artifact_ids + || unique_artifact_ids.len() != artifact_ids.len() + { failures.push(format!( "{observation_id} lacks sorted physical artifact provenance" )); } - for artifact_id in artifact_ids { - if !parsed.artifacts.contains_key(artifact_id) { + for artifact_id in &artifact_ids { + if !parsed.artifacts.contains_key(*artifact_id) { failures.push(format!( "{observation_id} cites unknown physical artifact {artifact_id}" )); } } - for reference in observation["evidence"] + let references = observation["evidence"] .as_array() .map(Vec::as_slice) - .unwrap_or_default() - { + .unwrap_or_default(); + let mut cited_artifact_ids = 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", + &format!("{observation_id}.evidence"), + ) { + cited_artifact_ids.insert(artifact_id); + if !unique_artifact_ids.contains(artifact_id) { + failures.push(format!( + "{observation_id} cites evidence outside its physical artifact set" + )); + } + } if let Err(error) = evidence_for(parsed, reference, observation_id) { failures.push(error); } } + let artifacts = artifact_ids + .iter() + .filter_map(|artifact_id| parsed.artifacts.get(*artifact_id)) + .collect::>(); + let semantic_match = match classification { + Some("ignoredClientEvidence") => { + !references.is_empty() + && cited_artifact_ids == unique_artifact_ids + && artifacts.iter().all(|artifact| { + artifact.role == "client" + && artifact.source_id == "client-content-control" + && matches!(artifact.state.as_str(), "captured" | "capped") + }) + } + Some("rotationSplit") => { + let source_ids = artifacts + .iter() + .map(|artifact| artifact.source_id.as_str()) + .collect::>(); + let lineages = artifacts + .iter() + .map(|artifact| artifact.rotation_lineage.as_str()) + .collect::>(); + let rotation_kinds = artifacts + .iter() + .map(|artifact| artifact.rotation_kind.as_str()) + .collect::>(); + references.is_empty() + && artifacts.len() >= 2 + && source_ids.len() == 1 + && lineages.len() == 1 + && lineages.first().is_some_and(|lineage| !lineage.is_empty()) + && rotation_kinds.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 !semantic_match { + failures.push(format!( + "{observation_id} classification is detached from exact physical coverage semantics" + )); + } } let requests = match required_array(expected, "artifactRequests", "expected") { @@ -1643,3 +1770,93 @@ fn unknown_semantics_collisions_and_output_reordering_fail_closed() { "closed-schema/collision/order mutations were accepted: {accepted:?}" ); } + +#[test] +fn one_physical_site_log_is_not_duplicated_per_distribution_point_subject() { + let manifest = read_json("content-version-mismatch", "manifest.json").expect("manifest loads"); + let mut physical_sources = BTreeSet::new(); + let mut duplicates = Vec::new(); + + for artifact in manifest["artifacts"] + .as_array() + .expect("artifacts are an array") + { + let identity = ( + artifact["producerHostHandle"] + .as_str() + .expect("producer handle"), + artifact["sanitizedSourcePath"] + .as_str() + .expect("sanitized source path"), + artifact["originalBasename"].as_str().expect("basename"), + artifact["rotation"]["kind"] + .as_str() + .expect("rotation kind"), + ); + if !physical_sources.insert(identity) { + duplicates.push(identity); + } + } + + assert!( + duplicates.is_empty(), + "one physical capture was duplicated to attach multiple workflow subjects: {duplicates:?}" + ); + + let mut falsely_narrowed = manifest.clone(); + falsely_narrowed["artifacts"][0] + .as_object_mut() + .expect("artifact is an object") + .remove("workflowSubjectBasis"); + falsely_narrowed["artifacts"][0]["workflowSubjectHandle"] = json!(EXACT_DP); + assert!( + validate_manifest( + &corpus_root().join("content-version-mismatch"), + &falsely_narrowed + ) + .is_err(), + "a shared physical site log was falsely narrowed to one DP despite containing another" + ); +} + +#[test] +fn source_local_classifications_are_bound_to_physical_coverage_semantics() { + let client_manifest = + read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); + let client_expected = + read_json("client-only-looking-request", "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 client_as_malformed = client_expected.clone(); + client_as_malformed["sourceLocalObservations"][0]["classification"] = + json!("malformedEvidence"); + if mutation_was_accepted( + "client-only-looking-request", + &client_manifest, + &client_as_malformed, + ) { + accepted.push("captured client evidence relabeled as malformed server evidence"); + } + + let mut split_as_client = rotation_expected.clone(); + split_as_client["sourceLocalObservations"][0]["classification"] = + json!("ignoredClientEvidence"); + if mutation_was_accepted("rotation-boundary", &rotation_manifest, &split_as_client) { + accepted.push("split server rotation relabeled as ignored client evidence"); + } + + let mut malformed_as_split = rotation_expected.clone(); + malformed_as_split["sourceLocalObservations"][1]["classification"] = json!("rotationSplit"); + if mutation_was_accepted("rotation-boundary", &rotation_manifest, &malformed_as_split) { + accepted.push("parse-failed source relabeled as a rotation split"); + } + + assert!( + accepted.is_empty(), + "source-local classifications were detached from physical coverage: {accepted:?}" + ); +} diff --git a/docs/sccm/preparation/issue-329-distribution-point-corpus.md b/docs/sccm/preparation/issue-329-distribution-point-corpus.md index 00c443f32..aeac2a4a6 100644 --- a/docs/sccm/preparation/issue-329-distribution-point-corpus.md +++ b/docs/sccm/preparation/issue-329-distribution-point-corpus.md @@ -30,8 +30,8 @@ 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` | exact DP handle | Receive and distribute | -| `server-dp-distribution` | `PkgXferMgr.log` | `siteServer` | exact DP handle | Transfer and retry | +| `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 | @@ -46,7 +46,8 @@ 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 - handle; + 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; - rotation kind, lineage, and fragment completeness; @@ -54,8 +55,12 @@ Each manifest preserves: byte count, and bounded relative evidence path; and - deterministic artifact identity and ordering. -The two DPs in `content-version-mismatch` are separate subjects even though -the site-server basenames and package/content identifiers overlap. +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. ## State and exact-key contract @@ -96,7 +101,8 @@ The outcome rules are conservative: - incomplete coverage remains `insufficientEvidence` with exact physical gap IDs and a bounded source ID; - rotation fragments and malformed evidence remain noncorrelatable - source-local observations; and + source-local observations whose classification is bound to exact physical + role, capture state, lineage, rotation kind, and fragment completeness; and - a client-only download record cannot become a DP transaction or DP failure. ## Coverage and request contract From d07ab1575d2d5a4360dc0f606064b8dbc6525170 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 22:33:15 -0400 Subject: [PATCH 049/422] test(sccm): enforce DP producer provenance --- ...ver_distribution_point_fixture_contract.rs | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index 3a5a2890a..fe58b8f12 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -32,6 +32,8 @@ const STATE_CHAIN: &[&str] = &[ const EXACT_PROFILE: &str = "dp-server-5.00.test-v1"; const EXACT_SITE: &str = "LAB"; const EXACT_DP: &str = "safe:dp:lab-dp-01"; +const EXACT_SITE_SERVER: &str = "safe:server:lab-pri-01"; +const EXACT_CLIENT: &str = "safe:client:lab-client-01"; fn corpus_root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -466,11 +468,19 @@ fn validate_manifest( "{artifact_id} loses the distribution-point workflow subject" )); } - if !artifact["producerHostHandle"] - .as_str() - .is_some_and(|value| value.starts_with("safe:")) - { - failures.push(format!("{artifact_id} lacks an opaque producer handle")); + let producer_host_handle = artifact["producerHostHandle"].as_str(); + let producer_matches_role = match role { + "siteServer" => producer_host_handle == Some(EXACT_SITE_SERVER), + "client" => producer_host_handle == Some(EXACT_CLIENT), + "distributionPoint" => { + producer_host_handle.is_some_and(|value| distribution_point_handles.contains(value)) + } + _ => false, + }; + if !producer_matches_role { + failures.push(format!( + "{artifact_id} producer handle is not in the exact role-specific namespace" + )); } if role == "distributionPoint" && (workflow_subject_handle.is_none() @@ -703,6 +713,7 @@ fn validate_manifest( } 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!( @@ -1638,6 +1649,19 @@ fn coverage_role_and_rotation_states_fail_closed() { accepted.push("basename reclassified the site-server producer as a DP"); } + let mut host_alias_manifest = absent_manifest.clone(); + host_alias_manifest["artifacts"][0]["producerHostHandle"] = json!(EXACT_DP); + if mutation_was_accepted("absent-dp", &host_alias_manifest, &absent_expected) { + accepted.push("site-server producer host collapsed onto its DP workflow subject"); + } + + let mut nonphysical_limit_manifest = absent_manifest.clone(); + nonphysical_limit_manifest["artifacts"][0]["collectionLimit"] = + json!({"byteLimit": 4096, "limitApplied": false}); + if mutation_was_accepted("absent-dp", &nonphysical_limit_manifest, &absent_expected) { + accepted.push("absent artifact invented a physical collection-limit policy"); + } + let rotation_manifest = read_json("rotation-boundary", "manifest.json").expect("manifest loads"); let rotation_expected = From b321265104b8283de2d583d91891fa6438aa1571 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 22:36:49 -0400 Subject: [PATCH 050/422] fix(sccm): authorize only catalog artifact scope --- .../cmtraceopen-parser/src/sccm/findings.rs | 420 +++++++++++++++++- .../tests/sccm_spine_contract.rs | 387 ++++++++++++++++ 2 files changed, 794 insertions(+), 13 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 5941fb858..fab1b4e53 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -108,6 +108,15 @@ impl SccmTerminalEvidenceKind { 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 { @@ -115,9 +124,9 @@ impl Serialize for SccmTerminalEvidenceKind { where S: Serializer, { - if matches!(self, Self::Unknown(value) if value == "observedFailure") { + if !self.has_canonical_serialized_form() { return Err(S::Error::custom( - "unknown terminal evidence kind must not shadow observedFailure", + "unknown terminal evidence kind must be canonical and must not shadow observedFailure", )); } serializer.serialize_str(self.serialized_name()) @@ -129,10 +138,16 @@ impl<'de> Deserialize<'de> for SccmTerminalEvidenceKind { where D: Deserializer<'de>, { - Ok(match String::deserialize(deserializer)? { + 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) } } @@ -752,6 +767,14 @@ fn is_bounded_request_reason( } 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) }); @@ -770,6 +793,367 @@ fn contains_rooted_path(reason: &str) -> bool { }) } +// 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(&tokens, &identity_ranges) + } else if contains_collection_directive { + collection_clause_is_catalog_bounded(&tokens, &identity_ranges) + } else { + narrative_clause_has_no_collection_scope(&tokens) + } + }) +} + +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( + tokens: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], +) -> bool { + if identity_ranges.is_empty() { + 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 confirmation_clause_is_non_authorizing( + tokens: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], +) -> bool { + 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 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 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; @@ -801,7 +1185,7 @@ fn has_unbounded_request_scope( requested_logical_id: &str, ) -> bool { let tokens = clause - .split(|character: char| !character.is_ascii_alphanumeric()) + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') .filter(|token| !token.is_empty()) .collect::>(); @@ -818,16 +1202,21 @@ fn has_unbounded_request_scope( fn is_collection_action(token: &str) -> bool { matches!( token, - "collect" - | "scan" - | "search" - | "walk" - | "traverse" + "archive" | "capture" + | "collect" + | "copy" + | "download" + | "enumerate" | "export" | "gather" | "inspect" + | "obtain" | "read" + | "scan" + | "search" + | "walk" + | "traverse" ) } @@ -866,7 +1255,9 @@ fn is_collection_container(token: &str) -> bool { matches!( token, "client" + | "clients" | "device" + | "devices" | "directory" | "directories" | "disk" @@ -878,8 +1269,11 @@ fn is_collection_container(token: &str) -> bool { | "folder" | "folders" | "machine" + | "machines" | "site" + | "sites" | "system" + | "systems" | "volume" | "volumes" ) @@ -997,7 +1391,7 @@ fn requested_artifact_identity_ranges( requested_basename: &str, requested_logical_id: &str, ) -> Vec<(usize, usize)> { - let basename = normalize_catalog_identity(requested_basename); + let basename = normalize_catalog_identity(catalog_log_stem(requested_basename)); let logical_id = normalize_catalog_identity(requested_logical_id); let mut ranges = tokens .iter() @@ -1017,7 +1411,7 @@ fn requested_artifact_identity_ranges( fn normalize_catalog_identity(identity: &str) -> String { identity .chars() - .filter(|character| character.is_ascii_alphanumeric()) + .filter(|character| character.is_ascii_alphanumeric() || *character == '_') .flat_map(char::to_lowercase) .collect() } @@ -1062,7 +1456,7 @@ fn target_is_bounded_to_requested_artifact( .any(|token| matches!(*token, "encryption" | "status"))) } "file" | "files" => identity_precedes_target || is_state_observation, - "logs" => identity_precedes_target, + "log" | "logs" => identity_precedes_target, _ => true, } } diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index ad89eba3b..27792eb16 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -236,6 +236,94 @@ const REVIEW_BOUNDED_NAMED_ARTIFACT_REQUESTS: [(&str, &str); 14] = [ ), ]; +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_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, @@ -1192,6 +1280,32 @@ fn finding_phase_unknown_values_require_canonical_standalone_serde() { ); } +#[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 "] { @@ -1564,6 +1678,279 @@ fn finding_review_bounded_named_artifact_matrix_passes_every_public_boundary() { ); } +#[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_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_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"); From 516db1f8fe08417a65bab7fb25102a6015901c42 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 22:41:22 -0400 Subject: [PATCH 051/422] test(sccm): harden management corpus contract --- .../co-management-intune-owned/manifest.json | 5 +- .../co-management-sccm-owned/manifest.json | 5 +- .../co-management-transitioning/manifest.json | 5 +- .../co-management-unknown/manifest.json | 3 +- .../management/mixed-unrelated/manifest.json | 25 +- .../notification-deferred/manifest.json | 10 +- .../notification-failure/manifest.json | 10 +- .../notification-received/manifest.json | 10 +- .../management/script-failure/manifest.json | 10 +- .../script-incomplete/manifest.json | 15 +- .../script-intune-handoff/manifest.json | 10 +- .../management/script-success/manifest.json | 10 +- .../manifest.json | 16 +- .../software-center-observed/manifest.json | 10 +- ...sccm_client_management_fixture_contract.rs | 901 ++++++++++++++++-- .../issue-326-client-management-corpus.md | 55 +- 16 files changed, 951 insertions(+), 149 deletions(-) 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 index 47e94fa19..a3ab14d53 100644 --- 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 @@ -21,7 +21,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/co-management-intune-owned/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", @@ -32,7 +32,8 @@ "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/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-sccm-owned/manifest.json index ce04b6946..f4228e6e9 100644 --- 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 @@ -21,7 +21,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/co-management-sccm-owned/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", @@ -32,7 +32,8 @@ "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/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-transitioning/manifest.json index 2f33dec77..2944fac37 100644 --- 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 @@ -21,7 +21,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/co-management-transitioning/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", @@ -32,7 +32,8 @@ "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/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/co-management-unknown/manifest.json index d4c109cb4..bb82087f1 100644 --- 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 @@ -32,7 +32,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" } ] } 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 index 9d568f726..6957bdbb0 100644 --- 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 @@ -21,7 +21,7 @@ "parserEligible": true, "captureState": "accessDenied", "relativePath": null, - "sanitizedSourcePath": "SYNTHETIC://client/management/mixed-unrelated/access/CcmNotificationAgent.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/mixed-unrelated/client-notification/access/CcmNotificationAgent.log", "pathFingerprint": "safe:path:326:mixed-notification-access", "sourceVersion": null, "encoding": null, @@ -32,7 +32,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "mixed-notification-invalid", @@ -43,7 +44,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-notification/current/CcmNotificationAgent.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/mixed-unrelated/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", @@ -54,7 +55,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "mixed-owner-unknown", @@ -65,7 +67,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/mixed-unrelated/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", @@ -76,7 +78,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "mixed-script-root-a", @@ -87,7 +90,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-scripts/root-a/current/Scripts.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/mixed-unrelated/root-a/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", @@ -98,7 +101,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "mixed-script-root-b", @@ -109,7 +113,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-scripts/root-b/current/Scripts.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/mixed-unrelated/root-b/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", @@ -120,7 +124,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" } ] } 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 index 3744a8a13..7f2a26333 100644 --- 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 @@ -21,7 +21,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-notification/current/CcmNotificationAgent.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/notification-deferred/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", @@ -32,7 +32,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "notification-deferred-owner", @@ -43,7 +44,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/notification-deferred/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", @@ -54,7 +55,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" } ] } 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 index 93ea122fe..4c4aefd3f 100644 --- 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 @@ -21,7 +21,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-notification/current/CcmNotificationAgent.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/notification-failure/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", @@ -32,7 +32,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "notification-failure-owner", @@ -43,7 +44,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/notification-failure/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", @@ -54,7 +55,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" } ] } 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 index 5ef9e488e..aca9a2b72 100644 --- 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 @@ -21,7 +21,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-notification/current/CcmNotificationAgent.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/notification-received/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", @@ -32,7 +32,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "notification-received-owner", @@ -43,7 +44,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/notification-received/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", @@ -54,7 +55,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" } ] } 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 index d5da1ce2f..c7b8f40ec 100644 --- 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 @@ -21,7 +21,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-scripts/current/Scripts.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/script-failure/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", @@ -32,7 +32,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "script-failure-owner", @@ -43,7 +44,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/script-failure/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", @@ -54,7 +55,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" } ] } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/manifest.json index 608e67f00..f9f271049 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/script-incomplete/manifest.json @@ -21,7 +21,7 @@ "parserEligible": true, "captureState": "capped", "relativePath": "evidence/client-scripts/current/Scripts.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/script-incomplete/Scripts.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/script-incomplete/client-scripts/Scripts.log", "pathFingerprint": "safe:path:326:script-incomplete-current", "sourceVersion": "5.00.TEST.3260", "encoding": "utf-8", @@ -32,7 +32,8 @@ "collectionLimit": { "capped": true, "limitBytes": 128 - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "script-incomplete-lo", @@ -43,7 +44,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-scripts/lo/Scripts.lo_", - "sanitizedSourcePath": "SYNTHETIC://client/management/script-incomplete/Scripts.lo_", + "sanitizedSourcePath": "SYNTHETIC://client/management/script-incomplete/client-scripts/Scripts.lo_", "pathFingerprint": "safe:path:326:script-incomplete-lo", "sourceVersion": "5.00.TEST.3260", "encoding": "utf-8", @@ -54,7 +55,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "script-incomplete-owner", @@ -65,7 +67,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/script-incomplete/CoManagementHandler.log", + "sanitizedSourcePath": "SYNTHETIC://client/management/script-incomplete/client-co-management/CoManagementHandler.log", "pathFingerprint": "safe:path:326:script-incomplete-owner", "sourceVersion": "5.00.TEST.3260", "encoding": "utf-8", @@ -76,7 +78,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" } ] } 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 index c4a723590..aa73e2664 100644 --- 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 @@ -21,7 +21,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-scripts/current/Scripts.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/script-intune-handoff/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", @@ -32,7 +32,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "script-intune-owner", @@ -43,7 +44,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/script-intune-handoff/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", @@ -54,7 +55,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" } ] } 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 index 20926ecc3..31afadd8b 100644 --- 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 @@ -21,7 +21,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-scripts/current/Scripts.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/script-success/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", @@ -32,7 +32,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "script-success-owner", @@ -43,7 +44,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/script-success/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", @@ -54,7 +55,8 @@ "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/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-insufficient/manifest.json index 0ee388bca..90806846b 100644 --- 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 @@ -32,7 +32,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "software-center-insufficient-malformed", @@ -43,7 +44,7 @@ "parserEligible": false, "captureState": "parseFailed", "relativePath": "evidence/client-software-center/current/SCClient_SYNTHETIC_2.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/software-center-insufficient/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", @@ -54,7 +55,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "software-center-insufficient-owner", @@ -76,7 +78,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "software-center-insufficient-unsupported", @@ -87,7 +90,7 @@ "parserEligible": false, "captureState": "unsupported", "relativePath": null, - "sanitizedSourcePath": "SYNTHETIC://client/management/software-center-insufficient/SCNotify_SYNTHETIC_1.log", + "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, @@ -98,7 +101,8 @@ "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/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/management/software-center-observed/manifest.json index 83126d82e..b980925dc 100644 --- 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 @@ -21,7 +21,7 @@ "parserEligible": false, "captureState": "captured", "relativePath": "evidence/client-software-center/current/SCClient_SYNTHETIC_1.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/software-center-observed/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", @@ -32,7 +32,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" }, { "artifactId": "software-center-observed-owner", @@ -43,7 +44,7 @@ "parserEligible": true, "captureState": "captured", "relativePath": "evidence/client-co-management/current/CoManagementHandler.log", - "sanitizedSourcePath": "SYNTHETIC://client/management/software-center-observed/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", @@ -54,7 +55,8 @@ "collectionLimit": { "capped": false, "limitBytes": null - } + }, + "capturedUtc": "2026-07-31T00:00:00Z" } ] } diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 1829ace0b..893ecd607 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -1,4 +1,8 @@ -use cmtraceopen_parser::models::log_entry::LogFormat; +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}; @@ -42,9 +46,9 @@ struct CorpusInventory { #[derive(Debug)] struct EvidenceRecord { - message: String, + fields: BTreeMap, timestamp: Option, - offset: Option, + ordering_state: SccmTimeOrderingState, source_version: String, } @@ -156,6 +160,37 @@ fn required_string<'a>(value: &'a Value, field: &str, context: &str) -> Result<& .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"), @@ -182,6 +217,26 @@ fn workflow_logical_artifacts(workflow: &str) -> Result<&'static [&'static str], } } +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, @@ -204,6 +259,7 @@ fn source_contract( fn validate_relative_path( relative_path: &str, + logical_artifact: &str, source_name: &str, rotation_kind: &str, ) -> Result<(), String> { @@ -220,6 +276,7 @@ fn validate_relative_path( .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() @@ -234,10 +291,11 @@ fn validate_relative_path( 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}/"); + let required_prefix = format!("SYNTHETIC://client/management/{scenario}/{logical_artifact}/"); let suffix = sanitized_source_path .strip_prefix(&required_prefix) .unwrap_or_default(); @@ -373,6 +431,11 @@ fn evidence_records( .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) @@ -403,19 +466,7 @@ fn evidence_records( )); } for line in &lines[start - 1..end] { - let (entries, _) = - cmtraceopen_parser::parser::ccm::parse_content(line, artifact_id, None); - if entries.len() != 1 || entries[0].format != LogFormat::Ccm { - return Err(format!( - "{artifact_id} cited line is not one complete CCM logical record" - )); - } - records.push(EvidenceRecord { - message: entries[0].message.clone(), - timestamp: entries[0].timestamp, - offset: entries[0].timezone_offset, - source_version: required_string(artifact, "sourceVersion", artifact_id)?.to_owned(), - }); + records.push(normalized_record(artifact, line)?); } } Ok(records) @@ -437,20 +488,180 @@ fn all_artifact_records( .map_err(|error| format!("{artifact_id} evidence is readable: {error}"))?; let mut records = Vec::new(); for line in contents.lines() { - let (entries, _) = cmtraceopen_parser::parser::ccm::parse_content(line, artifact_id, None); - if entries.len() != 1 || entries[0].format != LogFormat::Ccm { + 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!( - "{artifact_id} complete physical artifact has a malformed CCM record" + "{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}" )); } - records.push(EvidenceRecord { - message: entries[0].message.clone(), - timestamp: entries[0].timestamp, - offset: entries[0].timezone_offset, - source_version: required_string(artifact, "sourceVersion", artifact_id)?.to_owned(), - }); } - Ok(records) + 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> { @@ -471,18 +682,15 @@ fn allowed_phases(workflow: &str) -> Result<&'static [&'static str], String> { } } -fn phase_rank(workflow: &str, message: &str) -> Result { - let phases = message - .split_ascii_whitespace() - .filter_map(|token| token.strip_prefix("Phase=")) - .collect::>(); - if phases.len() != 1 { - return Err("cited operational record does not have one exact phase".to_owned()); - } +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(|phase| *phase == phases[0]) - .ok_or_else(|| format!("cited operational record has unknown phase {}", phases[0])) + .position(|candidate| *candidate == phase) + .ok_or_else(|| format!("cited operational record has unknown phase {phase}")) } fn validate_temporal_progression( @@ -501,8 +709,8 @@ fn validate_temporal_progression( let timestamp = record .timestamp .ok_or_else(|| format!("{transaction_id} cites a record without a usable timestamp"))?; - let rank = phase_rank(workflow, &record.message) - .map_err(|error| format!("{transaction_id}: {error}"))?; + let rank = + phase_rank(workflow, record).map_err(|error| format!("{transaction_id}: {error}"))?; phase_bounds .entry(rank) .and_modify(|(minimum, maximum)| { @@ -512,11 +720,13 @@ fn validate_temporal_progression( .or_insert((timestamp, timestamp)); } - if phase_bounds.keys().next() != Some(&0) - || phase_bounds.keys().next_back() != Some(&asserted_rank) - { + 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 run from receive to the asserted phase" + "{transaction_id} cited phases do not contain the required workflow progression" )); } @@ -550,6 +760,41 @@ fn validate_contract( 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 @@ -590,6 +835,38 @@ fn validate_contract( 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) @@ -648,13 +925,18 @@ fn validate_contract( )); } if let Some(relative_path) = relative_path { - validate_relative_path(relative_path, source_name, rotation_kind)?; + 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, source_name, sanitized_source_path)?; + validate_source_path( + scenario, + logical_artifact, + source_name, + sanitized_source_path, + )?; let path_fingerprint = required_string(artifact, "pathFingerprint", artifact_id)?; if !path_fingerprint.starts_with("safe:path:326:") || !path_fingerprints.insert(path_fingerprint.to_owned()) @@ -698,7 +980,7 @@ fn validate_contract( "accessDenied" | "unsupported" => { let source_path = required_string(artifact, "sanitizedSourcePath", artifact_id)?; - validate_source_path(scenario, source_name, source_path)?; + validate_source_path(scenario, logical_artifact, source_name, source_path)?; let path_fingerprint = required_string(artifact, "pathFingerprint", artifact_id)?; if !path_fingerprint.starts_with("safe:path:326:") @@ -724,7 +1006,7 @@ fn validate_contract( if !records.is_empty() && records .iter() - .any(|record| record.offset.is_some_and(|offset| offset.abs() > 1_439)) + .any(|record| record.ordering_state == SccmTimeOrderingState::OffsetInvalid) { invalid_offset_artifacts.insert(artifact_id.to_owned()); } @@ -779,6 +1061,11 @@ fn validate_contract( 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( @@ -811,6 +1098,11 @@ fn validate_contract( _ => "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 @@ -836,8 +1128,25 @@ fn validate_contract( } 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" @@ -889,17 +1198,15 @@ fn validate_contract( let workload = required_string(ownership, "workload", "ownership")?; if ownership_records.is_empty() || ownership_records.iter().any(|record| { - !record.message.contains(&format!("Workload={workload}")) - || !record - .message - .contains(&format!("Ownership={ownership_class}")) + 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.timestamp.is_none() - || record.offset.is_none_or(|offset| offset.abs() > 1_439) + record.ordering_state != SccmTimeOrderingState::NormalizedUtc + || record.timestamp.is_none() || !record.source_version.starts_with("5.00.TEST.") }) { return Err( @@ -909,24 +1216,24 @@ fn validate_contract( match ownership_class { "SccmOwned" if ownership_records.iter().any(|record| { - !record.message.contains("Disposition=Owned") - || !record.message.contains("Terminal=true") + 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.message.contains("Disposition=Handoff") - || !record.message.contains("Terminal=true") + 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.message.contains("Disposition=Transitioning") - || !record.message.contains("Terminal=false") + 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()); @@ -942,10 +1249,10 @@ fn validate_contract( } } else if !ownership_records .iter() - .any(|record| record.message.contains("Ownership=SccmOwned")) + .any(|record| record.fields.get("Ownership").map(String::as_str) == Some("SccmOwned")) || !ownership_records .iter() - .any(|record| record.message.contains("Ownership=IntuneOwned")) + .any(|record| record.fields.get("Ownership").map(String::as_str) == Some("IntuneOwned")) { return Err("cited unknown ownership is not an explicit contradiction".to_owned()); } @@ -977,6 +1284,11 @@ fn validate_contract( 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()); } @@ -988,9 +1300,27 @@ fn validate_contract( .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")?; + 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}")); @@ -1046,6 +1376,15 @@ fn validate_contract( )); } } + 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"))?; @@ -1085,7 +1424,7 @@ fn validate_contract( for record in &records { for field in fields { let value = required_string(key, field, transaction_id)?; - if !record.message.contains(&format!("{field}={value}")) { + 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" )); @@ -1106,8 +1445,8 @@ fn validate_contract( } let confidence = required_string(transaction, "confidence", transaction_id)?; if records.iter().any(|record| { - record.timestamp.is_none() - || record.offset.is_none_or(|offset| offset.abs() > 1_439) + record.ordering_state != SccmTimeOrderingState::NormalizedUtc + || record.timestamp.is_none() || !record.source_version.starts_with("5.00.TEST.") }) { return Err(format!( @@ -1127,11 +1466,10 @@ fn validate_contract( let state = required_string(transaction, "state", transaction_id)?; let has_record = |disposition: &str, terminal: bool| { records.iter().any(|record| { - record.message.contains(&format!("Phase={phase}")) - && record - .message - .contains(&format!("Disposition={disposition}")) - && record.message.contains(&format!("Terminal={terminal}")) + 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 { @@ -1159,11 +1497,11 @@ fn validate_contract( || last_successful_phase == phase || !has_record("Failed", true) || !records.iter().any(|record| { - record - .message - .contains(&format!("Phase={last_successful_phase}")) - && record.message.contains("Disposition=Succeeded") - && record.message.contains("Terminal=false") + 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!( @@ -1257,12 +1595,25 @@ fn validate_contract( .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")?; + 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}")); @@ -1302,10 +1653,28 @@ fn validate_contract( || 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" @@ -1339,13 +1708,21 @@ fn validate_contract( 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| effective_state(artifact) == Ok("captured")) + .is_some_and(|artifact| { + matches!( + effective_state(artifact), + Ok("captured" | "malformed" | "unsupported") + ) + }) }) => { return Err(format!( @@ -1452,13 +1829,13 @@ fn validate_contract( .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="] + let has_script_key = ["ScriptId", "ExecutionId", "ResourceHandle"] .iter() - .all(|token| record.message.contains(token)); + .all(|field| record.fields.contains_key(*field)); let has_notification_key = - ["NotificationId=", "ChannelId=", "ResourceHandle="] + ["NotificationId", "ChannelId", "ResourceHandle"] .iter() - .all(|token| record.message.contains(token)); + .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" @@ -1485,6 +1862,16 @@ fn validate_contract( "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:?}" @@ -1508,6 +1895,20 @@ fn mutation_was_accepted( 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!( @@ -1957,3 +2358,345 @@ fn unsupported_candidate_and_causal_claim_mutations_fail_closed() { "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]!> Date: Thu, 30 Jul 2026 22:55:35 -0400 Subject: [PATCH 052/422] fix(sccm): bind each collection action locally --- .../cmtraceopen-parser/src/sccm/findings.rs | 26 +++++++++- .../tests/sccm_spine_contract.rs | 48 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index fab1b4e53..28af50a95 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -989,7 +989,9 @@ fn collection_clause_is_catalog_bounded( tokens: &[RequestReasonToken<'_>], identity_ranges: &[(usize, usize)], ) -> bool { - if identity_ranges.is_empty() { + if identity_ranges.is_empty() + || !collection_actions_have_local_catalog_identity(tokens, identity_ranges) + { return false; } @@ -1009,6 +1011,28 @@ fn collection_clause_is_catalog_bounded( }) } +fn collection_actions_have_local_catalog_identity( + 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()); + if !tokens[action_index..segment_end] + .iter() + .any(|token| token_is_covered_by_identity(token, identity_ranges)) + { + return false; + } + } + true +} + fn confirmation_clause_is_non_authorizing( tokens: &[RequestReasonToken<'_>], identity_ranges: &[(usize, usize)], diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 27792eb16..a76fac2f6 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -313,6 +313,12 @@ const REVIEW_UNQUALIFIED_COLLECTION_ACTION_REASONS: [&str; 15] = [ "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_EXACT_MP_ARTIFACT_REQUESTS: [(&str, &str); 5] = [ ("mpCliReg", "Collect the complete MP_CliReg.log file."), ("mpGetAuth", "Collect the complete MP_GetAuth.log file."), @@ -1804,6 +1810,48 @@ fn finding_review_unqualified_collection_actions_fail_at_every_public_boundary() ); } +#[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_exact_mp_identity_passes_every_public_boundary() { let canonical = finding_with_gap_and_request("review-exact-mp-identity-parity"); From 2fcaa9ca9a13a12234c8e484dcaffd44f471a63b Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:02:26 -0400 Subject: [PATCH 053/422] test(sccm): prepare SUP WSUS diagnostic corpus --- .../server/software_update_point/README.md | 16 + .../server-sup-sync/site/current/WCM.log | 1 + .../incomplete/expected.json | 37 + .../incomplete/manifest.json | 19 + .../server-sup-sync/site/current/WCM.log | 1 + .../server-sup-sync/site/current/wsyncmgr.log | 2 + .../metadata-failure/expected.json | 35 + .../metadata-failure/manifest.json | 16 + .../server-sup-sync/site/current/wsyncmgr.log | 1 + .../server-sup-sync/site/lo_/wsyncmgr.log | 1 + .../server-sup-sync/sup/current/WSUSCtrl.log | 1 + .../rotation-boundary/expected.json | 41 + .../rotation-boundary/manifest.json | 19 + .../server-sup-sync/sup/current/SUPSetup.log | 1 + .../sup-setup-failure/expected.json | 30 + .../sup-setup-failure/manifest.json | 11 + .../server-sup-sync/site/current/WCM.log | 1 + .../server-sup-sync/site/current/wsyncmgr.log | 3 + .../server-sup-sync/sup/current/WSUSCtrl.log | 2 + .../supplemental-wsus-skipped/expected.json | 40 + .../supplemental-wsus-skipped/manifest.json | 22 + .../server-sup-sync/site/current/WCM.log | 1 + .../server-sup-sync/site/current/wsyncmgr.log | 1 + .../sync-retry/expected.json | 34 + .../sync-retry/manifest.json | 16 + .../server-sup-sync/site/current/WCM.log | 1 + .../server-sup-sync/site/current/wsyncmgr.log | 3 + .../server-sup-sync/sup/current/WSUSCtrl.log | 2 + .../sync-success/expected.json | 39 + .../sync-success/manifest.json | 70 + .../current/WUAHandler.log | 1 + .../server-sup-sync/site/current/WCM.log | 1 + .../server-sup-sync/site/current/wsyncmgr.log | 3 + .../server-sup-sync/sup/current/WSUSCtrl.log | 2 + .../unrelated-update-key/expected.json | 48 + .../unrelated-update-key/manifest.json | 22 + .../server-sup-sync/site/current/WCM.log | 1 + .../wcm-configuration-failure/expected.json | 30 + .../wcm-configuration-failure/manifest.json | 28 + .../server-sup-sync/site/current/WCM.log | 1 + .../server-sup-sync/site/current/wsyncmgr.log | 2 + .../server-sup-sync/sup/current/WSUSCtrl.log | 1 + .../wsus-health-failure/expected.json | 37 + .../wsus-health-failure/manifest.json | 19 + ..._software_update_point_fixture_contract.rs | 1837 +++++++++++++++++ .../issue-330-software-update-point-corpus.md | 149 ++ 46 files changed, 2649 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/README.md create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/incomplete/evidence/server-sup-sync/site/current/WCM.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/incomplete/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/incomplete/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/evidence/server-sup-sync/site/current/WCM.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/evidence/server-sup-sync/site/current/wsyncmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/metadata-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/site/current/wsyncmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/site/lo_/wsyncmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/sup/current/WSUSCtrl.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sup-setup-failure/evidence/server-sup-sync/sup/current/SUPSetup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sup-setup-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sup-setup-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/evidence/server-sup-sync/site/current/WCM.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/evidence/server-sup-sync/site/current/wsyncmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/evidence/server-sup-sync/sup/current/WSUSCtrl.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/supplemental-wsus-skipped/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/evidence/server-sup-sync/site/current/WCM.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/evidence/server-sup-sync/site/current/wsyncmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-retry/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/site/current/WCM.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/site/current/wsyncmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/sup/current/WSUSCtrl.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/client-updates-control/current/WUAHandler.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/server-sup-sync/site/current/WCM.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/server-sup-sync/site/current/wsyncmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/evidence/server-sup-sync/sup/current/WSUSCtrl.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/unrelated-update-key/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wcm-configuration-failure/evidence/server-sup-sync/site/current/WCM.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wcm-configuration-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wcm-configuration-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/evidence/server-sup-sync/site/current/WCM.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/evidence/server-sup-sync/site/current/wsyncmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/evidence/server-sup-sync/sup/current/WSUSCtrl.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/wsus-health-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_server_software_update_point_fixture_contract.rs create mode 100644 docs/sccm/preparation/issue-330-software-update-point-corpus.md 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..0444745ea --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/README.md @@ -0,0 +1,16 @@ +# 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. + +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/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..393a3e6e7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_software_update_point_fixture_contract.rs @@ -0,0 +1,1837 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::DateTime; +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_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 corpus_root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/software_update_point") +} + +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 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()) + .map(SccmRotation::Numbered) + .ok_or_else(|| "numbered rotation requires a u32 value".to_owned()), + "timestamped" => required_string(rotation, "value", "rotation") + .map(str::to_owned) + .map(SccmRotation::Timestamped), + 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('/').any(|segment| segment == "..") + && relative_path + .rsplit('/') + .next() + .is_some_and(|candidate| candidate == basename) +} + +#[derive(Debug)] +struct ParsedArtifact { + state: String, + source_id: String, + role: 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, +) -> 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 roles = manifest["topology"]["rolesObserved"] + .as_array() + .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 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 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"); + if !allowed_source(source_id, role, basename, source_kind) { + failures.push(format!( + "{artifact_id} has an uncatalogued source/producer/basename/grammar tuple" + )); + } + if artifact["workflowSubjectRole"] != "softwareUpdatePoint" + || artifact["workflowSubjectHandle"] != 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 artifact["producerHostHandle"].as_str() != 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| value.starts_with("synthetic:")) + || !artifact["sanitizedSourcePath"] + .as_str() + .is_some_and(|value| value.starts_with("SYNTHETIC://")) + { + failures.push(format!("{artifact_id} leaks or omits path provenance")); + } + if path_fingerprint.is_some_and(|value| !path_fingerprints.insert(value)) { + failures.push(format!("{artifact_id} reuses a physical path fingerprint")); + } + if !artifact["sourceVersion"] + .as_str() + .is_some_and(|value| value.starts_with("5.00.TEST.")) + { + failures.push(format!( + "{artifact_id} is outside the 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, + }; + if rotation_lineage.is_empty() || !rotation_value_shape_valid { + failures.push(format!( + "{artifact_id} has incomplete or incoherent rotation provenance" + )); + } + let rotation_value = artifact["rotation"]["value"] + .as_str() + .map(str::to_owned) + .or_else(|| { + artifact["rotation"]["value"] + .as_u64() + .map(|value| value.to_string()) + }) + .unwrap_or_default(); + let identity = ( + artifact["producerHostHandle"] + .as_str() + .unwrap_or_default() + .to_owned(), + artifact["sanitizedSourcePath"] + .as_str() + .unwrap_or_default() + .to_owned(), + basename.to_owned(), + rotation_kind.clone(), + rotation_value, + ); + 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_from_manifest(&artifact["rotation"]) { + 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 !relative_paths.insert(relative_path.to_owned()) { + failures.push(format!( + "{artifact_id} collides with an evidence destination" + )); + } + let fixture_path = scenario_root.join(relative_path); + let bytes = 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 matches!(state, "captured" | "capped") && 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 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(), + 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()); + } + if expected["stateChain"] + .as_array() + .map(|values| values.iter().filter_map(Value::as_str).collect::>()) + .as_deref() + != Some(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 physical 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 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_ids = transaction["coverageGapArtifactIds"] + .as_array() + .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_ids != sorted_gap_ids { + failures.push(format!( + "{transaction_id} coverage gaps are not sorted/unique" + )); + } + let expected_gap_ids = parsed + .artifacts + .iter() + .filter(|(_, artifact)| artifact.role != "client" && artifact.state != "captured") + .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" => {} + _ => 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 + && confidence == "high" + && confidence_ceiling == "high" => {} + ("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()); + } + for observation in source_local { + let observation_id = + required_string(observation, "observationId", "sourceLocal").unwrap_or("invalid"); + 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_ids = observation["artifactIds"] + .as_array() + .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_ids.is_empty() || artifact_ids != sorted_artifact_ids { + failures.push(format!( + "{observation_id} artifact IDs are not sorted/unique" + )); + } + let artifacts = artifact_ids + .iter() + .filter_map(|artifact_id| parsed.artifacts.get(*artifact_id)) + .collect::>(); + let references = observation["evidence"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default(); + let mut cited_ids = 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); + 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 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 + && 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 source_local_classes = source_local + .iter() + .filter_map(|observation| observation["classification"].as_str()) + .collect::>(); + let expected_source_local_classes: &[&str] = match scenario { + "rotation-boundary" => &["rotationSplit", "malformedEvidence"], + "unrelated-update-key" => &["ignoredClientEvidence"], + _ => &[], + }; + if source_local_classes != expected_source_local_classes { + failures.push(format!( + "{scenario} does not retain its exact source-local coverage observations" + )); + } + + 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> { + let scenario_root = corpus_root().join(scenario); + let parsed = validate_manifest(scenario, &scenario_root, manifest)?; + 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() +} + +#[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 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 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 mut key_alias = expected.clone(); + key_alias["transactions"][0]["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(); + terminal_removed["transactions"][0]["observations"][5]["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 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"][0]["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"][1]["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 mut split_as_client = rotation_expected.clone(); + split_as_client["sourceLocalObservations"][0]["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"][1]["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"][0]["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"][0]["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"][0].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 mut accepted = Vec::new(); + + let mut collision = manifest.clone(); + collision["artifacts"][1]["relativePath"] = collision["artifacts"][0]["relativePath"].clone(); + collision["artifacts"][1]["originalBasename"] = + collision["artifacts"][0]["originalBasename"].clone(); + if mutation_was_accepted("unrelated-update-key", &collision, &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(); + borrowed_update["transactions"][0]["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 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 duplicate_reference = + duplicate_evidence["transactions"][0]["observations"][0]["evidence"][0].clone(); + duplicate_evidence["transactions"][0]["observations"][0]["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(); + omitted_gap["transactions"][0]["coverageGapArtifactIds"] = json!([]); + omitted_gap["transactions"][0]["confidence"] = json!("high"); + omitted_gap["transactions"][0]["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(); + duplicate_fingerprint["artifacts"][1]["pathFingerprint"] = + duplicate_fingerprint["artifacts"][0]["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"][0]["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"][0]["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(); + late_parse_failure["artifacts"][2]["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:?}" + ); +} 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..b20d4909b --- /dev/null +++ b/docs/sccm/preparation/issue-330-software-update-point-corpus.md @@ -0,0 +1,149 @@ +# 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/completeness, collection timestamp, capture state, encoding, +byte cap, exact copied-byte count, and collision-safe evidence destination. +Nonphysical states omit encoding, byte, limit, relative-path, and fragment +completion facts. + +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 +`5.00.TEST.*` fixtures. It 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 physical 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 physical artifact IDs and states. + +Requests use only a catalogued source ID and one of: + +- `coverageAbsent` +- `coverageAccessDenied` +- `coverageCapped` +- `coverageMalformed` +- `coverageRotationSplit` + +A reason code must be backed by matching noncomplete physical 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. + +## 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. From ac3bbf341c95ac7b86fab1e9752d31d401a4ba2c Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:07:53 -0400 Subject: [PATCH 054/422] docs(sccm): clarify SUP physical provenance --- .../issue-330-software-update-point-corpus.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/sccm/preparation/issue-330-software-update-point-corpus.md b/docs/sccm/preparation/issue-330-software-update-point-corpus.md index b20d4909b..46d821eef 100644 --- a/docs/sccm/preparation/issue-330-software-update-point-corpus.md +++ b/docs/sccm/preparation/issue-330-software-update-point-corpus.md @@ -45,10 +45,11 @@ 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/completeness, collection timestamp, capture state, encoding, -byte cap, exact copied-byte count, and collision-safe evidence destination. -Nonphysical states omit encoding, byte, limit, relative-path, and fragment -completion facts. +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. 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, From b1f318df021352be9cee26c744a46b7b47fbea30 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:14:50 -0400 Subject: [PATCH 055/422] test(sccm): harden SUP review contracts --- ..._software_update_point_fixture_contract.rs | 195 +++++++++++++++--- .../issue-330-software-update-point-corpus.md | 3 +- 2 files changed, 171 insertions(+), 27 deletions(-) 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 index 393a3e6e7..495148f00 100644 --- 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 @@ -68,6 +68,52 @@ fn required_bool(value: &Value, field: &str, context: &str) -> Result 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], @@ -1193,8 +1239,15 @@ fn validate_expected( ("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 @@ -1584,14 +1637,18 @@ 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"][0]["key"]["syncRunId"] = json!("sync-other"); + 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(); - terminal_removed["transactions"][0]["observations"][5]["terminal"] = json!(false); + 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"); } @@ -1613,6 +1670,8 @@ 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); @@ -1621,12 +1680,12 @@ fn coverage_role_nonphysical_and_capture_time_fail_closed() { accepted.push("missing sources erased an observed SUP role"); } let mut host_alias = manifest.clone(); - host_alias["artifacts"][0]["producerHostHandle"] = json!(EXACT_SUP); + 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"][1]["collectionLimit"] = + 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"); @@ -1654,20 +1713,26 @@ fn source_local_semantics_ordering_and_transaction_uniqueness_fail_closed() { 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"][0]["classification"] = + 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"][1]["classification"] = json!("rotationSplit"); + 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"][0]["classification"] = + client_as_malformed["sourceLocalObservations"][ignored_client]["classification"] = json!("malformedEvidence"); if mutation_was_accepted( "unrelated-update-key", @@ -1677,7 +1742,7 @@ fn source_local_semantics_ordering_and_transaction_uniqueness_fail_closed() { accepted.push("client evidence was relabeled as malformed server evidence"); } let mut reversed = unrelated_expected.clone(); - reversed["transactions"][0]["observations"] + reversed["transactions"][unrelated_transaction]["observations"] .as_array_mut() .expect("observations are mutable") .reverse(); @@ -1685,7 +1750,7 @@ fn source_local_semantics_ordering_and_transaction_uniqueness_fail_closed() { accepted.push("reversed observations were accepted"); } let mut duplicated = unrelated_expected.clone(); - let duplicate = duplicated["transactions"][0].clone(); + let duplicate = duplicated["transactions"][unrelated_transaction].clone(); duplicated["transactions"] .as_array_mut() .expect("transactions are mutable") @@ -1704,13 +1769,20 @@ fn source_local_semantics_ordering_and_transaction_uniqueness_fail_closed() { 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 mut collision = manifest.clone(); - collision["artifacts"][1]["relativePath"] = collision["artifacts"][0]["relativePath"].clone(); - collision["artifacts"][1]["originalBasename"] = - collision["artifacts"][0]["originalBasename"].clone(); - if mutation_was_accepted("unrelated-update-key", &collision, &expected) { + 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(); @@ -1719,7 +1791,11 @@ fn physical_collisions_unknown_fields_and_update_key_borrowing_fail_closed() { accepted.push("unknown causal field"); } let mut borrowed_update = expected.clone(); - borrowed_update["transactions"][0]["key"]["updateId"] = json!("update-client-b"); + 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"); } @@ -1749,6 +1825,8 @@ fn scenario_cardinality_rotation_shape_and_provenance_fail_closed() { 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!([]); @@ -1756,9 +1834,16 @@ fn scenario_cardinality_rotation_shape_and_provenance_fail_closed() { accepted.push("required scenario transaction was deleted"); } let mut duplicate_evidence = success_expected.clone(); - let duplicate_reference = - duplicate_evidence["transactions"][0]["observations"][0]["evidence"][0].clone(); - duplicate_evidence["transactions"][0]["observations"][0]["evidence"] + 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); @@ -1766,9 +1851,13 @@ fn scenario_cardinality_rotation_shape_and_provenance_fail_closed() { accepted.push("one physical logical record was cited twice"); } let mut omitted_gap = supplemental_expected.clone(); - omitted_gap["transactions"][0]["coverageGapArtifactIds"] = json!([]); - omitted_gap["transactions"][0]["confidence"] = json!("high"); - omitted_gap["transactions"][0]["confidenceCeiling"] = json!("high"); + 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, @@ -1783,18 +1872,20 @@ fn scenario_cardinality_rotation_shape_and_provenance_fail_closed() { accepted.push("uncatalogued topology role was accepted"); } let mut duplicate_fingerprint = success_manifest.clone(); - duplicate_fingerprint["artifacts"][1]["pathFingerprint"] = - duplicate_fingerprint["artifacts"][0]["pathFingerprint"].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"][0]["rotation"]["value"] = json!("lo_"); + 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"][0]["rotation"] + missing_fragment_state["artifacts"][wcm]["rotation"] .as_object_mut() .expect("rotation is mutable") .remove("fragmentComplete"); @@ -1802,7 +1893,8 @@ fn scenario_cardinality_rotation_shape_and_provenance_fail_closed() { accepted.push("physical artifact omitted fragment completeness"); } let mut late_parse_failure = rotation_manifest.clone(); - late_parse_failure["artifacts"][2]["collectedUtc"] = json!("2026-07-30T20:00:00Z"); + 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"); } @@ -1835,3 +1927,54 @@ fn scenario_cardinality_rotation_shape_and_provenance_fail_closed() { "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"))); +} diff --git a/docs/sccm/preparation/issue-330-software-update-point-corpus.md b/docs/sccm/preparation/issue-330-software-update-point-corpus.md index 46d821eef..58d39192e 100644 --- a/docs/sccm/preparation/issue-330-software-update-point-corpus.md +++ b/docs/sccm/preparation/issue-330-software-update-point-corpus.md @@ -101,7 +101,8 @@ capture chronology. Time alone is not a join key. `captured`, `absent`, `accessDenied`, `capped`, `skipped`, `unsupported`, and `parseFailed` remain distinct manifest states. Expected coverage is the exact -sorted projection of physical artifact IDs and states. +sorted projection of all manifest artifact IDs and states, including +nonphysical coverage outcomes. Requests use only a catalogued source ID and one of: From 3ae3b364786563eecfec165b76c70b463c52eba0 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:14:54 -0400 Subject: [PATCH 056/422] test(sccm): close issue 325 review gaps --- .../inventory-compliance-metering/README.md | 7 + .../compliance/coverage-states/manifest.json | 4 +- .../inventory/coverage-states/manifest.json | 8 +- ...ry_compliance_metering_fixture_contract.rs | 749 +++++++++++++++++- ...nt-inventory-compliance-metering-corpus.md | 27 +- 5 files changed, 749 insertions(+), 46 deletions(-) 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 index 4b6de4dee..acaccbf8b 100644 --- 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 @@ -20,6 +20,13 @@ Every scenario contains: - 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. + 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 diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json index 950177edf..e5c23901c 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/manifest.json @@ -49,8 +49,8 @@ "kind": "ccmLog", "captureState": "accessDenied", "originalBasename": "CITaskMgr.log", - "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/CITaskMgr.log", - "pathFingerprint": "synthetic-compliance-coverage-states-access-denied-root-a", + "sanitizedSourcePath": "SYNTHETIC://root-b/CCM/Logs/CITaskMgr.log", + "pathFingerprint": "synthetic-compliance-coverage-states-access-denied-root-b", "rotation": { "kind": "current", "fragmentComplete": false diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json index 3df759d4a..1cf9a9a92 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/manifest.json @@ -157,8 +157,8 @@ "kind": "ccmLog", "captureState": "skipped", "originalBasename": "InventoryAgent.log", - "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgent.log", - "pathFingerprint": "synthetic-inventory-coverage-states-skipped-root-a", + "sanitizedSourcePath": "SYNTHETIC://root-d/CCM/Logs/InventoryAgent.log", + "pathFingerprint": "synthetic-inventory-coverage-states-skipped-root-d", "rotation": { "kind": "current", "fragmentComplete": false @@ -180,8 +180,8 @@ "kind": "ccmLog", "captureState": "unsupported", "originalBasename": "InventoryProvider.log", - "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryProvider.log", - "pathFingerprint": "synthetic-inventory-coverage-states-unsupported-root-a", + "sanitizedSourcePath": "SYNTHETIC://root-e/CCM/Logs/InventoryProvider.log", + "pathFingerprint": "synthetic-inventory-coverage-states-unsupported-root-e", "rotation": { "kind": "current", "fragmentComplete": false 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 index a2dd8b16d..fcc22bf39 100644 --- 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 @@ -197,6 +197,78 @@ fn admitted_phases(family: &str) -> Result<&'static [&'static str], String> { } } +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"), @@ -206,6 +278,103 @@ fn expected_logical_artifact(family: &str) -> Result<&'static str, String> { } } +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" | "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"), @@ -379,6 +548,10 @@ fn walk_files(root: &Path) -> Result, String> { Ok(files) } +fn normalize_manifest_relative_path(path: &str) -> String { + path.replace('\\', "/") +} + static TEMP_SCENARIO_SEQUENCE: AtomicUsize = AtomicUsize::new(0); struct TemporaryScenario { @@ -529,6 +702,44 @@ fn validate_relative_path(relative_path: &str, artifact_id: &str) -> Result<(), 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, @@ -722,7 +933,11 @@ fn evidence_backed_last_successful_phase<'a>( let disposition = record.fields.get("Disposition")?.as_str(); let completed = match disposition { "Succeeded" => true, - "Compliant" | "NonCompliant" => record_field_is(record, "ResultType", "Evaluation"), + "Compliant" | "NonCompliant" => { + record_field_is(record, "Family", "compliance") + && record_field_is(record, "Phase", "Evaluate") + && record_field_is(record, "ResultType", "Evaluation") + } _ => false, }; if !completed { @@ -810,6 +1025,10 @@ fn evidence_record_texts( 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(), @@ -832,6 +1051,32 @@ fn validate_contract( 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, &[ @@ -882,7 +1127,7 @@ fn validate_contract( require_canonical_string_field_order(artifacts, "artifactId", "manifest artifact")?; let mut artifacts_by_id = BTreeMap::new(); let mut relative_paths = BTreeSet::new(); - let mut sanitized_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(); @@ -900,6 +1145,16 @@ fn validate_contract( 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}"))?; @@ -946,6 +1201,45 @@ fn validate_contract( } 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 = artifact["relativePath"].as_str(); if physical != relative_path.is_some() { return Err(format!( @@ -980,10 +1274,15 @@ fn validate_contract( 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" { - let byte_limit = artifact["collectionLimit"]["byteLimit"] - .as_u64() - .ok_or_else(|| format!("{artifact_id} capped byteLimit is not an integer"))?; if artifact["collectionLimit"]["limitApplied"] != true || artifact["truncated"] != true || artifact["rotation"]["fragmentComplete"] != false @@ -993,6 +1292,13 @@ fn validate_contract( "{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!( @@ -1000,22 +1306,31 @@ fn validate_contract( )); } let sanitized_path = required_string(artifact, "sanitizedSourcePath", artifact_id)?; - if !sanitized_path.starts_with("SYNTHETIC://") || !sanitized_path.ends_with(basename) { - return Err(format!("{artifact_id} source path is not sanitized")); - } - if scenario == "same-minute-collision" - && !sanitized_paths.insert(sanitized_path.to_owned()) - { - return Err(format!( - "{artifact_id} has an aliased sanitized source path" - )); - } 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 let Some(version) = artifact["sourceVersion"].as_str() { @@ -1054,26 +1369,31 @@ fn validate_contract( } if capture_state != "absent" { let sanitized_path = required_string(artifact, "sanitizedSourcePath", artifact_id)?; - if !sanitized_path.starts_with("SYNTHETIC://") - || !sanitized_path.ends_with(basename) - { - return Err(format!( - "{artifact_id} attempted source path is unsanitized" - )); - } - if scenario == "same-minute-collision" - && !sanitized_paths.insert(sanitized_path.to_owned()) - { - return Err(format!( - "{artifact_id} has an aliased sanitized source path" - )); - } 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" + )); + } } } } @@ -1081,10 +1401,11 @@ fn validate_contract( let actual_files = walk_files(&scenario_root.join("evidence"))? .into_iter() .map(|path| { - path.strip_prefix(scenario_root) + let relative_path = path + .strip_prefix(scenario_root) .expect("walk root is below scenario") - .to_string_lossy() - .into_owned() + .to_string_lossy(); + normalize_manifest_relative_path(&relative_path) }) .collect::>(); if actual_files != referenced_files { @@ -1304,6 +1625,7 @@ fn validate_contract( .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 { @@ -1439,6 +1761,25 @@ fn validate_contract( 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"); @@ -1716,6 +2057,19 @@ fn assert_rejected_with( ); } +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!( @@ -2247,7 +2601,7 @@ fn exact_head_review_blocker_compliance_result_type_is_source_record_local() { "Disposition=NonCompliant Terminal=true]LOG]!>\n", "", "\n", @@ -2277,7 +2631,7 @@ fn exact_head_review_blocker_compliance_result_type_is_source_record_local() { &temporary.root, &manifest, &expected, - "source-record-local", + "compliance evaluation result is not source-record-local", ); } @@ -2471,7 +2825,7 @@ fn exact_head_review_blocker_transaction_identity_and_collision_topology_are_uni &scenario_root, &manifest, &expected, - "aliased sanitized source path", + "source topology", ); } @@ -2979,3 +3333,328 @@ fn invalid_timestamp_offsets_cannot_be_promoted_to_high_confidence() { &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_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_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/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md b/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md index d6facfabc..f9ee804c2 100644 --- a/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md +++ b/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md @@ -30,6 +30,9 @@ 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` @@ -77,7 +80,12 @@ The manifest is SCCM-specific preparation data and does not overload generic 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. +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. @@ -91,7 +99,8 @@ 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 - manifest artifacts plus output arrays use canonical stable ordering; + 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`; @@ -130,12 +139,17 @@ of: - client-to-server role swaps and workflow/log-family source injection; - unsafe relative paths, incorrect byte counts, and cross-root fingerprint - aliasing; + 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 structured fields, nested CCM envelopes, and compliance - result types borrowed from another source record; +- duplicate/conflicting or unknown structured fields, nested CCM envelopes, + source-to-phase violations, and compliance result types borrowed from another + source record; +- 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; @@ -159,6 +173,9 @@ of: 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. From f39692b7f424f11379b6b91ada8eb515a9f20d98 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:16:01 -0400 Subject: [PATCH 057/422] fix(sccm): bind collection targets to catalog identity --- .../cmtraceopen-parser/src/sccm/findings.rs | 153 +++++++++++++++++- .../tests/sccm_spine_contract.rs | 102 ++++++++++++ 2 files changed, 248 insertions(+), 7 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 28af50a95..d479418fc 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -829,7 +829,7 @@ fn reason_scope_is_within_catalog_artifact( if is_confirmation { confirmation_clause_is_non_authorizing(&tokens, &identity_ranges) } else if contains_collection_directive { - collection_clause_is_catalog_bounded(&tokens, &identity_ranges) + collection_clause_is_catalog_bounded(clause, &tokens, &identity_ranges) } else { narrative_clause_has_no_collection_scope(&tokens) } @@ -986,11 +986,12 @@ fn is_identity_continuation(character: char) -> bool { } fn collection_clause_is_catalog_bounded( + clause: &str, tokens: &[RequestReasonToken<'_>], identity_ranges: &[(usize, usize)], ) -> bool { if identity_ranges.is_empty() - || !collection_actions_have_local_catalog_identity(tokens, identity_ranges) + || !collection_actions_have_exact_catalog_targets(clause, tokens, identity_ranges) { return false; } @@ -1011,7 +1012,8 @@ fn collection_clause_is_catalog_bounded( }) } -fn collection_actions_have_local_catalog_identity( +fn collection_actions_have_exact_catalog_targets( + clause: &str, tokens: &[RequestReasonToken<'_>], identity_ranges: &[(usize, usize)], ) -> bool { @@ -1023,16 +1025,153 @@ fn collection_actions_have_local_catalog_identity( while let Some(action_index) = action_indices.next() { let segment_end = action_indices.peek().copied().unwrap_or(tokens.len()); - if !tokens[action_index..segment_end] - .iter() - .any(|token| token_is_covered_by_identity(token, identity_ranges)) - { + 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.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; + } + + 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( tokens: &[RequestReasonToken<'_>], identity_ranges: &[(usize, usize)], diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index a76fac2f6..3fdb5fa66 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -319,6 +319,25 @@ const REVIEW_COORDINATED_UNQUALIFIED_ACTION_REASONS: [&str; 3] = [ "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; 3] = [ + "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.", +]; + 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."), @@ -1852,6 +1871,89 @@ fn finding_review_coordinated_unqualified_actions_fail_at_every_public_boundary( ); } +#[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_exact_mp_identity_passes_every_public_boundary() { let canonical = finding_with_gap_and_request("review-exact-mp-identity-parity"); From 1ad3eab81493b91aacb7fb4311b57668f1266392 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:25:45 -0400 Subject: [PATCH 058/422] test(sccm): close SUP corpus review gaps --- ..._software_update_point_fixture_contract.rs | 281 +++++++++++++++++- 1 file changed, 266 insertions(+), 15 deletions(-) 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 index 495148f00..48df12fd0 100644 --- 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 @@ -36,6 +36,112 @@ 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 corpus_root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/sccm/server/software_update_point") @@ -269,13 +375,42 @@ fn source_path_is_bounded(relative_path: &str, basename: &str) -> bool { && relative_path.starts_with("evidence/") && !relative_path.starts_with('/') && !relative_path.contains('\\') - && !relative_path.split('/').any(|segment| segment == "..") + && 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 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, @@ -342,14 +477,15 @@ fn validate_manifest( failures.push("manifest topology is not the exact synthetic LAB SUP/WSUS scope".to_owned()); } - let roles = manifest["topology"]["rolesObserved"] - .as_array() + 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 roles != sorted_roles + if role_values.is_none_or(|values| values.len() != roles.len()) + || roles != sorted_roles || !roles.contains(&"siteServer") || !roles.contains(&"softwareUpdatePoint") || roles @@ -476,19 +612,22 @@ fn validate_manifest( )); } let path_fingerprint = artifact["pathFingerprint"].as_str(); - if !path_fingerprint.is_some_and(|value| value.starts_with("synthetic:")) + if !path_fingerprint.is_some_and(|value| prefixed_token_is_nonempty(value, "synthetic:")) || !artifact["sanitizedSourcePath"] .as_str() - .is_some_and(|value| value.starts_with("SYNTHETIC://")) + .is_some_and(sanitized_source_path_is_safe) { failures.push(format!("{artifact_id} leaks or omits path provenance")); } - if path_fingerprint.is_some_and(|value| !path_fingerprints.insert(value)) { + 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 !artifact["sourceVersion"] .as_str() - .is_some_and(|value| value.starts_with("5.00.TEST.")) + .is_some_and(|value| prefixed_token_is_nonempty(value, "5.00.TEST.")) { failures.push(format!( "{artifact_id} is outside the synthetic version profile" @@ -531,8 +670,8 @@ fn validate_manifest( artifact["sanitizedSourcePath"] .as_str() .unwrap_or_default() - .to_owned(), - basename.to_owned(), + .to_ascii_lowercase(), + basename.to_ascii_lowercase(), rotation_kind.clone(), rotation_value, ); @@ -599,7 +738,7 @@ fn validate_manifest( "{artifact_id} has an unsafe or mismatched evidence path" )); } - if !relative_paths.insert(relative_path.to_owned()) { + if !relative_paths.insert(relative_path.to_ascii_lowercase()) { failures.push(format!( "{artifact_id} collides with an evidence destination" )); @@ -881,11 +1020,12 @@ fn validate_expected( { failures.push("expected output loses the preparation/dependency boundary".to_owned()); } - if expected["stateChain"] - .as_array() + let state_values = expected["stateChain"].as_array(); + let state_chain = state_values .map(|values| values.iter().filter_map(Value::as_str).collect::>()) - .as_deref() - != Some(STATE_CHAIN) + .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()); } @@ -1023,6 +1163,33 @@ fn validate_expected( 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()) @@ -1978,3 +2145,87 @@ fn terminal_failure_with_optional_gap_has_a_medium_confidence_ceiling() { 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"); + } + + assert!( + accepted.is_empty(), + "required-phase/schema/path mutations were accepted: {accepted:?}" + ); +} From 649a5f204352d53100e73d7519971d06ec1d2445 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:35:04 -0400 Subject: [PATCH 059/422] docs(sccm): include nonphysical SUP coverage --- ...erver_software_update_point_fixture_contract.rs | 14 ++++++++++++++ .../issue-330-software-update-point-corpus.md | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) 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 index 48df12fd0..322bd8a03 100644 --- 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 @@ -2229,3 +2229,17 @@ fn required_phase_identity_and_manifest_strings_fail_closed() { "required-phase/schema/path 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" + ); +} diff --git a/docs/sccm/preparation/issue-330-software-update-point-corpus.md b/docs/sccm/preparation/issue-330-software-update-point-corpus.md index 58d39192e..92cdf66d8 100644 --- a/docs/sccm/preparation/issue-330-software-update-point-corpus.md +++ b/docs/sccm/preparation/issue-330-software-update-point-corpus.md @@ -112,7 +112,7 @@ Requests use only a catalogued source ID and one of: - `coverageMalformed` - `coverageRotationSplit` -A reason code must be backed by matching noncomplete physical coverage. There +A reason code must be backed by matching noncomplete manifest coverage. There is no free-form collection request in the preparation labels. ## Scenario matrix From f58959091075c9994c026c5e5819a6b325300b81 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:36:03 -0400 Subject: [PATCH 060/422] test(sccm): close nonphysical provenance types --- ...ry_compliance_metering_fixture_contract.rs | 144 +++++++++++++++++- 1 file changed, 137 insertions(+), 7 deletions(-) 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 index fcc22bf39..1c13036e2 100644 --- 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 @@ -1240,12 +1240,24 @@ fn validate_contract( )); } require_exact_object_fields(artifact, &exact_artifact_fields, "artifact")?; - let relative_path = artifact["relativePath"].as_str(); - if physical != relative_path.is_some() { - return Err(format!( - "{artifact_id} physical path does not match capture state {capture_state}" - )); - } + 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(relative_path) = relative_path { validate_relative_path(relative_path, artifact_id)?; @@ -1333,7 +1345,7 @@ fn validate_contract( } referenced_files.insert(relative_path.to_owned()); - if let Some(version) = artifact["sourceVersion"].as_str() { + if let Some(version) = source_version { if !version.starts_with("5.00.TEST.") { unknown_version_artifacts.insert(artifact_id.to_owned()); } @@ -3619,6 +3631,124 @@ fn review_blocker_capture_state_provenance_is_closed() { 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_evidence_line_identity_is_unique_and_nonoverlapping() { let (scenario_root, manifest, expected) = load_contract("inventory", "recovery-contradictory"); From dfd4acf75480cc5820f2374299779e3322d39fb2 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:37:51 -0400 Subject: [PATCH 061/422] fix(sccm): reject non-authorizing collection clauses --- .../cmtraceopen-parser/src/sccm/findings.rs | 113 +++++++++++++++++- .../tests/sccm_spine_contract.rs | 54 ++++++++- 2 files changed, 164 insertions(+), 3 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index d479418fc..0656eb42c 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -819,7 +819,14 @@ fn reason_scope_is_within_catalog_artifact( return false; } - request_clauses(reason).all(|clause| { + let clauses = request_clauses(reason).collect::>(); + let has_collection_directive = clauses.iter().any(|clause| { + tokenize_request_reason(clause) + .iter() + .any(|token| is_collection_action(token.text)) + }); + + clauses.into_iter().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"); @@ -830,6 +837,12 @@ fn reason_scope_is_within_catalog_artifact( confirmation_clause_is_non_authorizing(&tokens, &identity_ranges) } else if contains_collection_directive { collection_clause_is_catalog_bounded(clause, &tokens, &identity_ranges) + } else if has_collection_directive { + // An authorized action cannot lend its identity to another + // strong-punctuation clause. Keep evidence context attached as a + // bounded narrative suffix instead of treating a second command + // as non-authorizing prose. + false } else { narrative_clause_has_no_collection_scope(&tokens) } @@ -1080,7 +1093,7 @@ fn collection_action_has_exact_catalog_target( { trailing.next(); } - let trailing = trailing.collect::>(); + let trailing = trailing.copied().collect::>(); if trailing.is_empty() { return true; } @@ -1094,6 +1107,9 @@ fn collection_action_has_exact_catalog_target( if !is_collection_narrative_introducer(trailing[0].text) { return false; } + if !narrative_tokens_are_non_authorizing(clause, &trailing, identity_ranges) { + return false; + } trailing.iter().enumerate().all(|(index, token)| { if !is_action_coordinator(token.text) { @@ -1259,6 +1275,99 @@ fn narrative_clause_has_no_collection_scope(tokens: &[RequestReasonToken<'_>]) - }) } +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) + && tokens.get(index + 1).is_some() + && !passive_subject_is_evidence(tokens, index + 1, identity_ranges) + }) +} + +fn passive_subject_is_evidence( + tokens: &[RequestReasonToken<'_>], + predicate_index: usize, + identity_ranges: &[(usize, usize)], +) -> bool { + let auxiliary_index = tokens[..predicate_index] + .iter() + .rposition(|token| is_passive_auxiliary(token.text)); + let Some(auxiliary_index) = auxiliary_index else { + return false; + }; + 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 is_passive_auxiliary(token: &str) -> bool { + matches!( + token, + "are" + | "be" + | "been" + | "being" + | "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_wide_scope_token(token: &str) -> bool { token == "wide" || token diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 3fdb5fa66..f213b8ded 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -332,10 +332,20 @@ const REVIEW_UNBOUND_COLLECTION_TARGET_REASONS: [&str; 10] = [ "Collect PolicyAgent.log and retrieve credentials.", ]; -const REVIEW_SAFE_COLLECTION_NARRATIVE_REASONS: [&str; 3] = [ +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_EXACT_MP_ARTIFACT_REQUESTS: [(&str, &str); 5] = [ @@ -1954,6 +1964,48 @@ fn finding_review_safe_collection_narratives_pass_at_every_public_boundary() { ); } +#[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_exact_mp_identity_passes_every_public_boundary() { let canonical = finding_with_gap_and_request("review-exact-mp-identity-parity"); From accb9809f73733f0335b5af681bbc56ec2be921f Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:41:49 -0400 Subject: [PATCH 062/422] fix(sccm): preserve safe evidence clauses --- .../cmtraceopen-parser/src/sccm/findings.rs | 48 +++++++++++++++++-- .../tests/sccm_spine_contract.rs | 47 ++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 0656eb42c..97223a741 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -839,10 +839,9 @@ fn reason_scope_is_within_catalog_artifact( collection_clause_is_catalog_bounded(clause, &tokens, &identity_ranges) } else if has_collection_directive { // An authorized action cannot lend its identity to another - // strong-punctuation clause. Keep evidence context attached as a - // bounded narrative suffix instead of treating a second command - // as non-authorizing prose. - false + // strong-punctuation clause. Only independently safe evidence + // observations remain non-authorizing. + narrative_clause_is_safe_observation(clause, &tokens, &identity_ranges) } else { narrative_clause_has_no_collection_scope(&tokens) } @@ -1275,6 +1274,21 @@ fn narrative_clause_has_no_collection_scope(tokens: &[RequestReasonToken<'_>]) - }) } +fn narrative_clause_is_safe_observation( + clause: &str, + tokens: &[RequestReasonToken<'_>], + identity_ranges: &[(usize, usize)], +) -> bool { + narrative_tokens_are_non_authorizing(clause, tokens, identity_ranges) + && 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 narrative_tokens_are_non_authorizing( clause: &str, tokens: &[RequestReasonToken<'_>], @@ -1368,6 +1382,32 @@ fn is_evidence_narrative_subject(token: &str) -> bool { ) } +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 diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index f213b8ded..c35b67b09 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -348,6 +348,12 @@ const REVIEW_NON_AUTHORIZING_COLLECTION_LANGUAGE_REASONS: [&str; 6] = [ "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_EXACT_MP_ARTIFACT_REQUESTS: [(&str, &str); 5] = [ ("mpCliReg", "Collect the complete MP_CliReg.log file."), ("mpGetAuth", "Collect the complete MP_GetAuth.log file."), @@ -2006,6 +2012,47 @@ fn finding_review_non_authorizing_collection_language_fails_at_every_public_boun ); } +#[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_exact_mp_identity_passes_every_public_boundary() { let canonical = finding_with_gap_and_request("review-exact-mp-identity-parity"); From a398d1ef595f173339e0566edfd1949cb740c904 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:42:53 -0400 Subject: [PATCH 063/422] docs(sccm): align SUP coverage terminology --- .../sccm_server_software_update_point_fixture_contract.rs | 6 +++++- .../preparation/issue-330-software-update-point-corpus.md | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) 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 index 322bd8a03..1e799d385 100644 --- 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 @@ -1066,7 +1066,7 @@ fn validate_expected( 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 physical manifest projection".to_owned()); + failures.push("coverage is not the exact sorted manifest projection".to_owned()); } let transactions = match required_array(expected, "transactions", "expected") { @@ -2242,4 +2242,8 @@ fn bounded_request_documentation_includes_nonphysical_manifest_coverage() { !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/docs/sccm/preparation/issue-330-software-update-point-corpus.md b/docs/sccm/preparation/issue-330-software-update-point-corpus.md index 92cdf66d8..34072258f 100644 --- a/docs/sccm/preparation/issue-330-software-update-point-corpus.md +++ b/docs/sccm/preparation/issue-330-software-update-point-corpus.md @@ -85,7 +85,7 @@ 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 physical coverage remains `insufficientEvidence`, retains exact +- 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; From 96716bbc6e69b94508db66d0ef2565dd704bf47e Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:46:39 -0400 Subject: [PATCH 064/422] fix(sccm): bind passive evidence predicates --- .../cmtraceopen-parser/src/sccm/findings.rs | 52 +++++++++++++++---- .../tests/sccm_spine_contract.rs | 48 +++++++++++++++++ 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 97223a741..d870d4bc7 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -1315,22 +1315,16 @@ fn has_unbound_passive_artifact_request( ) -> bool { tokens.iter().enumerate().any(|(index, token)| { is_passive_auxiliary(token.text) - && tokens.get(index + 1).is_some() - && !passive_subject_is_evidence(tokens, index + 1, identity_ranges) + && (!passive_subject_is_evidence(tokens, index, identity_ranges) + || !passive_predicate_is_evidence_state(tokens, index, identity_ranges)) }) } fn passive_subject_is_evidence( tokens: &[RequestReasonToken<'_>], - predicate_index: usize, + auxiliary_index: usize, identity_ranges: &[(usize, usize)], ) -> bool { - let auxiliary_index = tokens[..predicate_index] - .iter() - .rposition(|token| is_passive_auxiliary(token.text)); - let Some(auxiliary_index) = auxiliary_index else { - return false; - }; let subject_start = tokens[..auxiliary_index] .iter() .rposition(|token| { @@ -1344,6 +1338,46 @@ fn passive_subject_is_evidence( }) } +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, diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index c35b67b09..14ff64b13 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -354,6 +354,12 @@ const REVIEW_SAFE_STRONG_PUNCTUATION_NARRATIVE_REASONS: [&str; 3] = [ "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_EXACT_MP_ARTIFACT_REQUESTS: [(&str, &str); 5] = [ ("mpCliReg", "Collect the complete MP_CliReg.log file."), ("mpGetAuth", "Collect the complete MP_GetAuth.log file."), @@ -2053,6 +2059,48 @@ fn finding_review_safe_strong_punctuation_narratives_pass_at_every_public_bounda ); } +#[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_exact_mp_identity_passes_every_public_boundary() { let canonical = finding_with_gap_and_request("review-exact-mp-identity-parity"); From 08edfa8dc2ae700ea9ba8b49b19c5f55edcd7d7f Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:52:11 -0400 Subject: [PATCH 065/422] test(sccm): surface unknown coverage profiles --- ...ry_compliance_metering_fixture_contract.rs | 108 +++++++++++++++++- 1 file changed, 103 insertions(+), 5 deletions(-) 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 index 1c13036e2..0b88f92e6 100644 --- 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 @@ -1258,6 +1258,9 @@ fn validate_contract( )) } }; + if source_version.is_some_and(|version| !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)?; @@ -1345,11 +1348,7 @@ fn validate_contract( } referenced_files.insert(relative_path.to_owned()); - if let Some(version) = source_version { - if !version.starts_with("5.00.TEST.") { - unknown_version_artifacts.insert(artifact_id.to_owned()); - } - } else { + if source_version.is_none() { return Err(format!( "{artifact_id} physical source has no sourceVersion" )); @@ -3749,6 +3748,105 @@ fn review_blocker_nonphysical_optional_field_json_types_are_closed() { 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-{capture_state}" + ), + "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_evidence_line_identity_is_unique_and_nonoverlapping() { let (scenario_root, manifest, expected) = load_contract("inventory", "recovery-contradictory"); From 04f41a63582e4f222bb7db9e64512497ea45106a Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:52:26 -0400 Subject: [PATCH 066/422] test(sccm): reject non-string SUP identifiers --- ..._software_update_point_fixture_contract.rs | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) 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 index 1e799d385..17b99f226 100644 --- 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 @@ -1350,16 +1350,18 @@ fn validate_expected( let confidence_ceiling = required_string(transaction, "confidenceCeiling", transaction_id).unwrap_or("invalid"); - let gap_ids = transaction["coverageGapArtifactIds"] - .as_array() + 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_ids != sorted_gap_ids { + 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 sorted/unique" + "{transaction_id} coverage gaps are not exact sorted strings" )); } let expected_gap_ids = parsed @@ -1523,16 +1525,19 @@ fn validate_expected( { failures.push(format!("{observation_id} is not safely source-local")); } - let artifact_ids = observation["artifactIds"] - .as_array() + 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_ids.is_empty() || artifact_ids != sorted_artifact_ids { + 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 sorted/unique" + "{observation_id} artifact IDs are not exact sorted strings" )); } let artifacts = artifact_ids @@ -2224,6 +2229,29 @@ fn required_phase_identity_and_manifest_strings_fail_closed() { 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:?}" From 6c917c2531f6159964e7f27a3564ebdacec37103 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:54:32 -0400 Subject: [PATCH 067/422] test(sccm): add hierarchy replication corpus --- .../hierarchy_and_replication/README.md | 16 + .../origin/current/sender.log | 1 + .../absent-remote-source/expected.json | 21 + .../absent-remote-source/manifest.json | 12 + .../origin/current/replmgr.log | 1 + .../backlog-retry/expected.json | 21 + .../backlog-retry/manifest.json | 11 + .../origin/current/sender.log | 1 + .../clock-offset-unknown/expected.json | 24 + .../clock-offset-unknown/manifest.json | 12 + .../origin/current/replmgr.log | 2 + .../origin/current/sender.log | 1 + .../healthy-link/expected.json | 29 + .../healthy-link/manifest.json | 14 + .../origin/current/replmgr.log | 1 + .../incomplete/expected.json | 14 + .../incomplete/manifest.json | 11 + .../origin/current/sender.log | 1 + .../receiver-processing-failure/expected.json | 25 + .../receiver-processing-failure/manifest.json | 12 + .../origin/current/sender.log | 2 + .../recovery/expected.json | 27 + .../recovery/manifest.json | 12 + .../origin/current/sender.log | 1 + .../origin/lo_/sender.lo_ | 1 + .../rotation-boundary/expected.json | 14 + .../rotation-boundary/manifest.json | 12 + .../origin/current/sender.log | 2 + .../sender-failure/expected.json | 29 + .../sender-failure/manifest.json | 11 + .../origin/current/sender.log | 1 + .../topology-mismatch/expected.json | 17 + .../topology-mismatch/manifest.json | 12 + ...rarchy_and_replication_fixture_contract.rs | 1553 +++++++++++++++++ .../issue-331-hierarchy-replication-corpus.md | 89 + 35 files changed, 2013 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/README.md create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/absent-remote-source/evidence/server-hierarchy-transfer/origin/current/sender.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/absent-remote-source/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/absent-remote-source/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/backlog-retry/evidence/server-hierarchy-control/origin/current/replmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/backlog-retry/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/backlog-retry/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/evidence/server-hierarchy-transfer/origin/current/sender.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-control/origin/current/replmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/origin/current/sender.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/incomplete/evidence/server-hierarchy-control/origin/current/replmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/incomplete/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/incomplete/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/receiver-processing-failure/evidence/server-hierarchy-transfer/origin/current/sender.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/receiver-processing-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/receiver-processing-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/evidence/server-hierarchy-transfer/origin/current/sender.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/rotation-boundary/evidence/server-hierarchy-transfer/origin/current/sender.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/rotation-boundary/evidence/server-hierarchy-transfer/origin/lo_/sender.lo_ create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/rotation-boundary/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/rotation-boundary/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/sender-failure/evidence/server-hierarchy-transfer/origin/current/sender.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/sender-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/sender-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/evidence/server-hierarchy-transfer/origin/current/sender.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_server_hierarchy_and_replication_fixture_contract.rs create mode 100644 docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md 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..7eb9d807b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/README.md @@ -0,0 +1,16 @@ +# 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 raw CCM files from the existing hierarchy catalog are present: +`replmgr.log`, `sender.log`, `despool.log`, and `rcmctrl.log`. Every complete +record includes the semantic `SYNTHETIC FIXTURE` marker and exact synthetic +message/link/site/profile fields. Partial rotation/cap fixtures retain the +marker but intentionally do not form a logical CCM record. + +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..556827c9c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/absent-remote-source/expected.json @@ -0,0 +1,21 @@ +{ + "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-transfer","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["despool.log"],"reasonCode":"coverageAbsent"}], + "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..88a164117 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/backlog-retry/expected.json @@ -0,0 +1,21 @@ +{ + "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":"deferred","classification":"blockedOrDeferred","confidence":"medium","confidenceCeiling":"medium", + "coverageGapArtifactIds":[], + "observations":[{"observationId":"backlog-01-queue","phase":"queueOrSerialize","disposition":"retrying","terminal":false,"evidence":[{"artifactId":"backlog-01-replmgr","startLine":1,"endLine":1}]}] + }], + "sourceLocalObservations": [], + "artifactRequests": [], + "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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/expected.json new file mode 100644 index 000000000..6741886b2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/expected.json @@ -0,0 +1,24 @@ +{ + "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-transfer","producerRole":"siteServer","direction":"both","targetSiteCode":"CHD","basenames":["despool.log","sender.log"],"reasonCode":"invalidOffset"}], + "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/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-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/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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/receiver-processing-failure/expected.json new file mode 100644 index 000000000..de2f137e9 --- /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":"failed","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high", + "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": [], + "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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/expected.json new file mode 100644 index 000000000..4fa90eb15 --- /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":"recovered","classification":"success","confidence":"high","confidenceCeiling":"high", + "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": [], + "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..479639e5a --- /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":"lo_","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..5103e071b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/sender-failure/expected.json @@ -0,0 +1,29 @@ +{ + "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":"failed","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","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":"failed","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","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": [], + "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..1ef15b8e8 --- /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":"MULTI","originHostHandle":"safe:server:lab-pri-01","targetHostHandle":"safe:server:multiple","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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/expected.json new file mode 100644 index 000000000..92d6c6c77 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/expected.json @@ -0,0 +1,17 @@ +{ + "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": [], + "sourceLocalObservations": [ + {"observationId":"mismatch-01-origin","classification":"topologyMismatch","confidence":"low","correlationEligible":false,"artifactIds":["mismatch-01-sender"],"evidence":[{"artifactId":"mismatch-01-sender","startLine":1,"endLine":1}]}, + {"observationId":"mismatch-02-target","classification":"topologyMismatch","confidence":"low","correlationEligible":false,"artifactIds":["mismatch-02-despool"],"evidence":[{"artifactId":"mismatch-02-despool","startLine":1,"endLine":1}]} + ], + "artifactRequests": [], + "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..93408547c --- /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":"MIXED","originHostHandle":"safe:server:lab-pri-01","targetHostHandle":"safe:server:mixed","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/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..3f8091d37 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_hierarchy_and_replication_fixture_contract.rs @@ -0,0 +1,1553 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::DateTime; +use cmtraceopen_parser::sccm::{ + classify_artifact_name, normalize_ccm_artifact, SccmArtifact, SccmArtifactFamily, + SccmCoverageState, SccmEvidence, SccmRole, SccmRotation, SccmTimeOrderingState, +}; +use serde_json::Value; + +const SCENARIOS: &[&str] = &[ + "absent-remote-source", + "backlog-retry", + "clock-offset-unknown", + "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"; + +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_segmented_path(value: &str, prefix: &str) -> bool { + value.strip_prefix(prefix).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 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), + "lo_" 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")); + } + if fields.insert(name.to_owned(), value.to_owned()).is_some() { + return Err(format!("duplicate fixture field {name}")); + } + } + Ok(fields) +} + +fn normalized_records( + scenario: &str, + manifest: &Value, +) -> BTreeMap<(String, u32, u32), SccmEvidence> { + let mut records = BTreeMap::new(); + for artifact in manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + { + let state = artifact["captureState"].as_str().unwrap_or_default(); + if !matches!(state, "captured" | "capped") { + continue; + } + let artifact_id = artifact["artifactId"] + .as_str() + .expect("artifact ID is a string"); + let relative_path = artifact["relativePath"] + .as_str() + .expect("physical artifact has a relative path"); + let content = std::fs::read_to_string(corpus_root().join(scenario).join(relative_path)) + .expect("fixture evidence is readable UTF-8"); + let model = SccmArtifact { + artifact_id: artifact_id.to_owned(), + display_name: artifact["originalBasename"] + .as_str() + .expect("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"]).expect("rotation is valid"), + coverage: coverage_state(state).expect("coverage is valid"), + encoding: Some("utf-8".to_owned()), + }; + for record in normalize_ccm_artifact(model, &content) { + let line_start = record + .reference + .line_start + .expect("normalized evidence has a start line"); + let line_end = record + .reference + .line_end + .expect("normalized evidence has an end line"); + assert!( + records + .insert((artifact_id.to_owned(), line_start, line_end), record) + .is_none(), + "{scenario}: duplicate physical logical evidence" + ); + } + } + records +} + +fn phase_is_owned_by(basename: &str, phase: &str) -> bool { + matches!( + (basename, phase), + ("replmgr.log", "initiate" | "queueOrSerialize") + | ("sender.log", "send") + | ("despool.log", "receive" | "process" | "healthyOrTerminal") + | ("rcmctrl.log", "acknowledge" | "healthyOrTerminal") + ) +} + +fn expected_transaction_ids(scenario: &str) -> &'static [&'static str] { + match scenario { + "absent-remote-source" => &["hierarchy:msg-absent-01:LAB:CHD:link-lab-chd"], + "backlog-retry" => &["hierarchy:msg-backlog-01:LAB:CHD:link-lab-chd"], + "clock-offset-unknown" => &["hierarchy:msg-clock-01:LAB:CHD:link-lab-chd"], + "healthy-link" => &["hierarchy:msg-healthy-01:LAB:CHD:link-lab-chd"], + "receiver-processing-failure" => &["hierarchy:msg-receiver-01:LAB:CHD:link-lab-chd"], + "recovery" => &["hierarchy:msg-recovery-01:LAB:CHD:link-lab-chd"], + "sender-failure" => &[ + "hierarchy:msg-send-chd:LAB:CHD:link-lab-chd", + "hierarchy:msg-send-sec:LAB:SEC:link-lab-sec", + ], + "incomplete" | "rotation-boundary" | "topology-mismatch" => &[], + _ => &[], + } +} + +fn expected_observation_ids(scenario: &str) -> &'static [&'static str] { + match scenario { + "absent-remote-source" => &["absent-01-send"], + "backlog-retry" => &["backlog-01-queue"], + "clock-offset-unknown" => &["clock-01-send", "clock-02-process"], + "healthy-link" => &[ + "healthy-01-initiate", + "healthy-02-queue", + "healthy-03-send", + "healthy-04-receive", + "healthy-05-process", + "healthy-06-acknowledge", + "healthy-07-terminal", + ], + "receiver-processing-failure" => &[ + "receiver-01-send", + "receiver-02-receive", + "receiver-03-process", + ], + "recovery" => &[ + "recovery-01-retry", + "recovery-02-send", + "recovery-03-receive", + "recovery-04-process", + "recovery-05-terminal", + ], + "sender-failure" => &["sender-01-chd-failure", "sender-02-sec-failure"], + "incomplete" | "rotation-boundary" | "topology-mismatch" => &[], + _ => &[], + } +} + +fn expected_source_local_ids(scenario: &str) -> &'static [&'static str] { + match scenario { + "incomplete" => &["incomplete-01-fragment"], + "rotation-boundary" => &["rotation-01-split"], + "topology-mismatch" => &["mismatch-01-origin", "mismatch-02-target"], + _ => &[], + } +} + +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 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", + "rolesObserved", + ], + ) { + failures.push("manifest contains an unsupported field or shape".to_owned()); + } + 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() else { + failures.push("artifactId is not a 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["sourceVersion"] + .as_str() + .is_some_and(|value| value.starts_with("5.00.TEST.") && value.len() > 10) + || !artifact["sanitizedSourcePath"] + .as_str() + .is_some_and(|value| safe_segmented_path(value, "SYNTHETIC://")) + || !artifact["pathFingerprint"].as_str().is_some_and(|value| { + value + .strip_prefix("synthetic:") + .is_some_and(|suffix| !suffix.is_empty()) + }) + { + failures.push(format!("{artifact_id}: invalid typed provenance")); + } + 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(str::is_empty) + { + failures.push(format!("{artifact_id}: invalid rotation provenance")); + } + match state { + Some("captured" | "capped" | "parseFailed") => { + let relative_path = artifact["relativePath"].as_str(); + if relative_path.is_none_or(|value| !safe_segmented_path(value, "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")); + } + } + 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()); + } + + 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 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 transactions = expected["transactions"].as_array(); + let transaction_ids = transactions + .into_iter() + .flatten() + .filter_map(|transaction| transaction["transactionId"].as_str()) + .collect::>(); + if transaction_ids != expected_transaction_ids(scenario) { + failures.push("transaction identity/cardinality matrix changed".to_owned()); + } + for transaction in transactions.into_iter().flatten() { + 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()); + } + 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("coverage gap IDs are not exact sorted strings".to_owned()); + } + if transaction["confidence"] == "high" + && (transaction["confidenceCeiling"] != "high" + || transaction["topologyCompatibility"] != "exact" + || transaction["timestampOrdering"] != "usable" + || transaction["terminalEvidence"] != true + || !gap_ids.is_empty()) + { + failures + .push("high confidence bypasses topology/time/terminal/coverage gates".to_owned()); + } + 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()); + } + 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"]) + || reference["artifactId"].as_str().is_none() + || reference["startLine"] + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .is_none() + || reference["endLine"] + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .is_none() + { + failures.push("evidence reference is not exact and typed".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::>(); + if observation_ids != expected_observation_ids(scenario) { + failures.push("observation identity/cardinality matrix changed".to_owned()); + } + let source_local_ids = expected["sourceLocalObservations"] + .as_array() + .into_iter() + .flatten() + .filter_map(|observation| observation["observationId"].as_str()) + .collect::>(); + if source_local_ids != expected_source_local_ids(scenario) { + failures.push("source-local identity/cardinality matrix changed".to_owned()); + } + + 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 = artifacts + .iter() + .map(|artifact| { + ( + artifact["artifactId"].as_str(), + artifact["direction"].as_str(), + artifact["originalBasename"].as_str(), + artifact["captureState"].as_str(), + ) + }) + .collect::>(); + let reversed = artifacts + .iter() + .rev() + .map(|artifact| { + ( + artifact["artifactId"].as_str(), + artifact["direction"].as_str(), + artifact["originalBasename"].as_str(), + artifact["captureState"].as_str(), + ) + }) + .collect::>(); + assert_eq!( + canonical, reversed, + "{scenario}: input order changed candidate projection" + ); + } +} + +#[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")); + let coverage_by_id = coverage + .iter() + .filter_map(|row| { + Some(( + row["artifactId"].as_str()?.to_owned(), + row["state"].as_str()?.to_owned(), + )) + }) + .collect::>(); + 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")); + assert_ne!( + coverage_by_id.get(artifact_id).map(String::as_str), + Some("captured"), + "{scenario}: complete capture was labeled a gap" + ); + } + } + } +} + +#[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["producerHostHandle"] + .as_str() + .is_some_and(|value| value.starts_with("safe:server:")) + || !artifact["sanitizedSourcePath"] + .as_str() + .is_some_and(|value| safe_segmented_path(value, "SYNTHETIC://")) + || !artifact["pathFingerprint"].as_str().is_some_and(|value| { + value + .strip_prefix("synthetic:") + .is_some_and(|suffix| !suffix.is_empty()) + }) + || !artifact["sourceVersion"] + .as_str() + .is_some_and(|value| value.starts_with("5.00.TEST.") && value.len() > 10) + { + 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 !safe_segmented_path(relative_path, "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 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; + } + 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_utc = i64::MIN; + 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 !matches!( + (disposition, terminal), + ("succeeded", false | true) + | ("failed", true) + | ("retrying" | "deferred", false) + ) { + 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 record.timestamp.ordering_state + != SccmTimeOrderingState::NormalizedUtc + || record.timestamp.utc_millis.is_none() + || record + .timestamp + .utc_millis + .is_some_and(|utc| utc < prior_utc) + { + failures.push(format!( + "{scenario}/{transaction_id}: unusable or reversed time" + )); + } + if let Some(utc) = record.timestamp.utc_millis { + prior_utc = utc; + } + } + 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" | "clock-offset-unknown" => &[( + Some("incomplete"), + Some("insufficientEvidence"), + Some("low"), + )], + "backlog-retry" => &[(Some("deferred"), Some("blockedOrDeferred"), Some("medium"))], + "healthy-link" => &[(Some("succeeded"), Some("success"), Some("high"))], + "incomplete" | "rotation-boundary" | "topology-mismatch" => &[], + "receiver-processing-failure" => { + &[(Some("failed"), Some("confirmedFailure"), Some("high"))] + } + "recovery" => &[(Some("recovered"), Some("success"), Some("high"))], + "sender-failure" => &[ + (Some("failed"), Some("confirmedFailure"), Some("high")), + (Some("failed"), Some("confirmedFailure"), Some("high")), + ], + _ => &[], + }; + 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); + let artifacts = manifest["artifacts"] + .as_array() + .expect("artifacts are an array") + .iter() + .filter_map(|artifact| { + Some(( + artifact["artifactId"].as_str()?.to_owned(), + artifact.clone(), + )) + }) + .collect::>(); + + let requests = expected["artifactRequests"] + .as_array() + .expect("artifact requests are an array"); + let request_keys = requests + .iter() + .map(|request| { + ( + request["sourceId"].as_str(), + request["direction"].as_str(), + request["targetSiteCode"].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 { + 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 basenames = request["basenames"] + .as_array() + .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(); + if !matches!( + source_id, + Some("server-hierarchy-control" | "server-hierarchy-transfer") + ) || request["producerRole"] != "siteServer" + || !matches!(direction, Some("origin" | "target" | "both")) + || target_site.is_none_or(|site| { + site.len() != 3 + || !site + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + }) + || !matches!( + reason, + Some( + "coverageAbsent" + | "coverageCapped" + | "coverageRotationSplit" + | "invalidOffset" + ) + ) + || basenames != sorted_basenames + || basenames.iter().any(|basename| { + !matches!( + *basename, + "replmgr.log" | "sender.log" | "sender.lo_" | "despool.log" + ) + }) + { + failures.push(format!("{scenario}: request is broad or malformed")); + } + let backed = match reason { + Some("coverageAbsent") => artifacts.values().any(|artifact| { + artifact["sourceId"] == request["sourceId"] + && artifact["direction"] == request["direction"] + && artifact["captureState"] == "absent" + }), + Some("coverageCapped") => artifacts.values().any(|artifact| { + artifact["sourceId"] == request["sourceId"] + && artifact["direction"] == request["direction"] + && artifact["captureState"] == "capped" + }), + Some("coverageRotationSplit") => { + artifacts + .values() + .filter(|artifact| { + artifact["sourceId"] == request["sourceId"] + && artifact["direction"] == request["direction"] + && artifact["rotation"]["fragmentComplete"] == false + }) + .count() + >= 2 + } + Some("invalidOffset") => records.values().any(|record| { + record.timestamp.ordering_state == SccmTimeOrderingState::OffsetInvalid + }), + _ => false, + }; + if !backed { + failures.push(format!( + "{scenario}: request is not backed by exact coverage/time evidence" + )); + } + } + + let request_reason_codes = requests + .iter() + .filter_map(|request| request["reasonCode"].as_str()) + .collect::>(); + let expected_reasons: &[&str] = match *scenario { + "absent-remote-source" => &["coverageAbsent"], + "clock-offset-unknown" => &["invalidOffset"], + "incomplete" => &["coverageCapped"], + "rotation-boundary" => &["coverageRotationSplit"], + _ => &[], + }; + 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"], + "topology-mismatch" => &["topologyMismatch", "topologyMismatch"], + _ => &[], + }; + 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"); + for required in [ + "Raw CCM remains the transport grammar", + "timestamp proximity\nalone cannot create a transaction", + "A missing remote artifact\nis a coverage state, not evidence that the remote role is absent or broken", + "time alone is never eligible", + "not an\nacceptance source", + ] { + 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}")); + assert!( + identity_and_schema_failures(scenario, &manifest, &expected).is_empty(), + "{scenario}: committed schema is invalid" + ); + } + + 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 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 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:?}" + ); +} 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..22006fa1e --- /dev/null +++ b/docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md @@ -0,0 +1,89 @@ +# 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. + +## 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. 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. + +## 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. +Bounded follow-up requests name only the relevant hierarchy source, direction, +target site, 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 | +| `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. From 1182ffda37f6bc7475e4cf382b7220795709b0fe Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:55:35 -0400 Subject: [PATCH 068/422] test(sccm): reject duplicate profile gaps --- ...ry_compliance_metering_fixture_contract.rs | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) 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 index 0b88f92e6..7fb666546 100644 --- 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 @@ -1580,8 +1580,12 @@ fn validate_contract( observed_states.push(effective_state(artifact)?); observed_rotations.insert(required_string(&artifact["rotation"], "kind", artifact_id)?); observed_artifact_ids.insert(artifact_id.to_owned()); - if kind == "unknownProfile" { - unknown_profile_observations.insert(artifact_id.to_owned()); + if kind == "unknownProfile" + && !unknown_profile_observations.insert(artifact_id.to_owned()) + { + return Err(format!( + "{observation_id} is a duplicate unknown-profile observation for {artifact_id}" + )); } if kind == "invalidOffset" { invalid_offset_observations.insert(artifact_id.to_owned()); @@ -3847,6 +3851,45 @@ fn review_blocker_unknown_versions_are_profile_gaps_for_every_coverage_state() { 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"); From e1c65146a9f3d479278d50386e7335a17bbb9d7b Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 23:58:47 -0400 Subject: [PATCH 069/422] test(sccm): bind hierarchy topology keys --- .../sender-failure/manifest.json | 2 +- .../topology-mismatch/manifest.json | 2 +- ...rarchy_and_replication_fixture_contract.rs | 83 +++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) 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 index 1ef15b8e8..65cc6db0a 100644 --- 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 @@ -4,7 +4,7 @@ "syntheticFixture": true, "scenario": "sender-failure", "bundle": {"bundleRole":"server","workflow":"hierarchyAndReplication","capturedUtc":"2026-07-30T20:00:00Z"}, - "topology": {"originSiteCode":"LAB","targetSiteCode":"MULTI","originHostHandle":"safe:server:lab-pri-01","targetHostHandle":"safe:server:multiple","rolesObserved":["siteServer"]}, + "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/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/manifest.json index 93408547c..591787061 100644 --- 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 @@ -4,7 +4,7 @@ "syntheticFixture": true, "scenario": "topology-mismatch", "bundle": {"bundleRole":"server","workflow":"hierarchyAndReplication","capturedUtc":"2026-07-30T20:00:00Z"}, - "topology": {"originSiteCode":"LAB","targetSiteCode":"MIXED","originHostHandle":"safe:server:lab-pri-01","targetHostHandle":"safe:server:mixed","rolesObserved":["siteServer"]}, + "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/sccm_server_hierarchy_and_replication_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_hierarchy_and_replication_fixture_contract.rs index 3f8091d37..89a2afe02 100644 --- 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 @@ -281,6 +281,19 @@ fn object_has_only(value: &Value, fields: &[&str]) -> bool { .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() +} + fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec { let mut failures = Vec::new(); if !object_has_only( @@ -304,11 +317,53 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val "targetSiteCode", "originHostHandle", "targetHostHandle", + "additionalTargets", "rolesObserved", ], ) { failures.push("manifest contains an unsupported field or shape".to_owned()); } + let topology = &manifest["topology"]; + 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) + || topology["originHostHandle"] + .as_str() + .is_none_or(|value| !value.starts_with("safe:server:")) + || primary_target_host.is_none_or(|value| !value.starts_with("safe:server:")) + { + 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(); + if let (Some(site), Some(host)) = (primary_target_site, primary_target_host) { + topology_target_sites.insert(site); + topology_target_hosts.insert(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| !value.starts_with("safe:server:")) + || !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()); + } + } + } 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")) @@ -539,6 +594,13 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val { 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()); + } let gap_values = transaction["coverageGapArtifactIds"].as_array(); let gap_ids = gap_values .map(|values| values.iter().filter_map(Value::as_str).collect::>()) @@ -967,6 +1029,7 @@ fn hierarchy_transactions_require_exact_keys_topology_time_and_citations() { 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()) @@ -999,6 +1062,15 @@ fn hierarchy_transactions_require_exact_keys_topology_time_and_citations() { 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(), @@ -1530,6 +1602,17 @@ fn hierarchy_schema_and_identity_mutations_fail_closed() { 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()); From 00af7f85a62b8f2cac7bae43d120d26cd0a9fc67 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 00:06:26 -0400 Subject: [PATCH 070/422] test(sccm): close SUP source-local contract --- ..._software_update_point_fixture_contract.rs | 153 +++++++++++++++--- 1 file changed, 134 insertions(+), 19 deletions(-) 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 index 17b99f226..199d356b1 100644 --- 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 @@ -142,6 +142,17 @@ fn expected_observation_signature( } } +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") @@ -1498,9 +1509,33 @@ fn validate_expected( 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, &[ @@ -1540,15 +1575,26 @@ fn validate_expected( "{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 = observation["evidence"] - .as_array() - .map(Vec::as_slice) - .unwrap_or_default(); + 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, @@ -1558,6 +1604,16 @@ fn validate_expected( ); 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")); } @@ -1616,21 +1672,6 @@ fn validate_expected( )); } } - let source_local_classes = source_local - .iter() - .filter_map(|observation| observation["classification"].as_str()) - .collect::>(); - let expected_source_local_classes: &[&str] = match scenario { - "rotation-boundary" => &["rotationSplit", "malformedEvidence"], - "unrelated-update-key" => &["ignoredClientEvidence"], - _ => &[], - }; - if source_local_classes != expected_source_local_classes { - failures.push(format!( - "{scenario} does not retain its exact source-local coverage observations" - )); - } - let requests = match required_array(expected, "artifactRequests", "expected") { Ok(value) => value, Err(error) => { @@ -2258,6 +2299,80 @@ fn required_phase_identity_and_manifest_strings_fail_closed() { ); } +#[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 bounded_request_documentation_includes_nonphysical_manifest_coverage() { let contract = From c26a304fe9197a3c6ea45e1328e2ab9f94769f92 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 00:10:50 -0400 Subject: [PATCH 071/422] fix(sccm): require positive request narratives --- .../cmtraceopen-parser/src/sccm/findings.rs | 111 ++++++++++++++---- .../tests/sccm_spine_contract.rs | 51 ++++++++ 2 files changed, 142 insertions(+), 20 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index d870d4bc7..d5162551f 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -819,14 +819,7 @@ fn reason_scope_is_within_catalog_artifact( return false; } - let clauses = request_clauses(reason).collect::>(); - let has_collection_directive = clauses.iter().any(|clause| { - tokenize_request_reason(clause) - .iter() - .any(|token| is_collection_action(token.text)) - }); - - clauses.into_iter().all(|clause| { + 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"); @@ -837,13 +830,11 @@ fn reason_scope_is_within_catalog_artifact( confirmation_clause_is_non_authorizing(&tokens, &identity_ranges) } else if contains_collection_directive { collection_clause_is_catalog_bounded(clause, &tokens, &identity_ranges) - } else if has_collection_directive { - // An authorized action cannot lend its identity to another - // strong-punctuation clause. Only independently safe evidence - // observations remain non-authorizing. - narrative_clause_is_safe_observation(clause, &tokens, &identity_ranges) } else { - narrative_clause_has_no_collection_scope(&tokens) + // 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) } }) } @@ -1106,7 +1097,7 @@ fn collection_action_has_exact_catalog_target( if !is_collection_narrative_introducer(trailing[0].text) { return false; } - if !narrative_tokens_are_non_authorizing(clause, &trailing, identity_ranges) { + if !narrative_suffix_is_non_authorizing(clause, &trailing, identity_ranges) { return false; } @@ -1280,13 +1271,93 @@ fn narrative_clause_is_safe_observation( identity_ranges: &[(usize, usize)], ) -> bool { narrative_tokens_are_non_authorizing(clause, tokens, identity_ranges) - && tokens.iter().any(|token| { - token_is_covered_by_identity(token, identity_ranges) - || is_evidence_narrative_subject(token.text) - }) + && 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| is_evidence_narrative_predicate(token.text)) + .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( diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 14ff64b13..424fd066f 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -360,6 +360,15 @@ const REVIEW_EVIDENCE_SUBJECT_UNBOUND_PASSIVE_REASONS: [&str; 3] = [ "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_EXACT_MP_ARTIFACT_REQUESTS: [(&str, &str); 5] = [ ("mpCliReg", "Collect the complete MP_CliReg.log file."), ("mpGetAuth", "Collect the complete MP_GetAuth.log file."), @@ -2101,6 +2110,48 @@ fn finding_review_evidence_subject_cannot_authorize_passive_target_at_any_public ); } +#[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_exact_mp_identity_passes_every_public_boundary() { let canonical = finding_with_gap_and_request("review-exact-mp-identity-parity"); From 4e0d93332a5550ad41ea8976d1f3a77ba9e1c413 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 00:12:41 -0400 Subject: [PATCH 072/422] test(sccm): close bundle ordering contract --- ...ry_compliance_metering_fixture_contract.rs | 62 ++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) 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 index 7fb666546..5c032c80d 100644 --- 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 @@ -1114,8 +1114,23 @@ fn validate_contract( return Err("bundle identity is not the sanitized client fixture identity".to_owned()); } let bundle_id = required_string(&manifest["bundle"], "bundleId", "bundle")?; - if !bundle_id.starts_with(&format!("sccm-325-{family}-")) { - return Err("bundleId is not issue/family scoped".to_owned()); + 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"] @@ -3506,6 +3521,49 @@ fn review_blocker_manifest_and_ccm_structured_vocabularies_are_closed() { 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_sources_own_exact_phases_and_workflow_semantics() { let (temporary, manifest, mut expected) = copied_contract_with_evidence_replacements( From d6d5c697a4fa756078366bebe34b14519f5a0c5e Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 00:21:55 -0400 Subject: [PATCH 073/422] fix(sccm): bound confirmation request grammar --- .../cmtraceopen-parser/src/sccm/findings.rs | 83 ++++++++++++++++++- .../tests/sccm_spine_contract.rs | 55 ++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index d5162551f..4ecf4141a 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -827,7 +827,7 @@ fn reason_scope_is_within_catalog_artifact( tokens.iter().any(|token| is_collection_action(token.text)); if is_confirmation { - confirmation_clause_is_non_authorizing(&tokens, &identity_ranges) + confirmation_clause_is_non_authorizing(clause, &tokens, &identity_ranges) } else if contains_collection_directive { collection_clause_is_catalog_bounded(clause, &tokens, &identity_ranges) } else { @@ -1179,9 +1179,19 @@ fn has_unbound_dotted_target( } 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)) + || !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 @@ -1249,6 +1259,77 @@ fn confirmation_clause_is_non_authorizing( true } +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| { diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 424fd066f..1dabffa71 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -369,6 +369,19 @@ const REVIEW_STANDALONE_AND_INFLECTED_COLLECTION_REASONS: [&str; 6] = [ "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_EXACT_MP_ARTIFACT_REQUESTS: [(&str, &str); 5] = [ ("mpCliReg", "Collect the complete MP_CliReg.log file."), ("mpGetAuth", "Collect the complete MP_GetAuth.log file."), @@ -2152,6 +2165,48 @@ fn finding_review_standalone_and_inflected_collection_requests_fail_at_every_pub ); } +#[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_exact_mp_identity_passes_every_public_boundary() { let canonical = finding_with_gap_and_request("review-exact-mp-identity-parity"); From a79a679508fa078175a9f9bd3041ea3467e5c3db Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 00:25:13 -0400 Subject: [PATCH 074/422] test(sccm): harden SUP physical evidence gates --- .../evidence/server-sup-sync/site/lo_/WCM.log | 1 + .../server-sup-sync/sup/numbered/WSUSCtrl.log | 1 + .../site/numbered/wsyncmgr.log | 1 + ..._software_update_point_fixture_contract.rs | 267 ++++++++++++++---- 4 files changed, 212 insertions(+), 58 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/site/lo_/WCM.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/sup/numbered/WSUSCtrl.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/site/numbered/wsyncmgr.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/site/lo_/WCM.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/site/lo_/WCM.log new file mode 100644 index 000000000..0b356e668 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/site/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/rotation-boundary/evidence/server-sup-sync/sup/numbered/WSUSCtrl.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/sup/numbered/WSUSCtrl.log new file mode 100644 index 000000000..da47a6ebd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/sup/numbered/WSUSCtrl.log @@ -0,0 +1 @@ + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/site/numbered/wsyncmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/site/numbered/wsyncmgr.log new file mode 100644 index 000000000..ffbedd7e0 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/site/numbered/wsyncmgr.log @@ -0,0 +1 @@ + Some(EXACT_CLIENT), _ => None, }; - if artifact["producerHostHandle"].as_str() != expected_producer { + if Some(producer_host) != expected_producer { failures.push(format!( "{artifact_id} producer handle is not exact for its declared role" )); @@ -636,10 +646,7 @@ fn validate_manifest( { failures.push(format!("{artifact_id} reuses a physical path fingerprint")); } - if !artifact["sourceVersion"] - .as_str() - .is_some_and(|value| prefixed_token_is_nonempty(value, "5.00.TEST.")) - { + if !prefixed_token_is_nonempty(source_version, "5.00.TEST.") { failures.push(format!( "{artifact_id} is outside the synthetic version profile" )); @@ -789,7 +796,7 @@ fn validate_manifest( failures.push(format!("{artifact_id} lacks a synthetic fixture marker")); } - if matches!(state, "captured" | "capped") && source_kind == "ccmLog" { + if source_kind == "ccmLog" { let content = String::from_utf8_lossy(&bytes); let artifact_model = SccmArtifact { artifact_id: artifact_id.to_owned(), @@ -804,61 +811,70 @@ fn validate_manifest( encoding: artifact["encoding"].as_str().map(str::to_owned), }; let normalized = normalize_ccm_artifact(artifact_model, &content); - 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 - }) - { + if state == "parseFailed" { + if !normalized.is_empty() { failures.push(format!( - "{artifact_id} has unusable evidence/artifact/capture chronology" + "{artifact_id} is parseFailed but contains usable normalized CCM evidence" )); } - if record - .ccm_source_file - .as_deref() - .is_none_or(|value| !value.contains(".cpp:")) - { + } else { + if artifact["rotation"]["fragmentComplete"] == false && !normalized.is_empty() { failures.push(format!( - "{artifact_id} loses distinct CCM code-origin provenance" + "{artifact_id} exposes a logical record from an incomplete fragment" )); } - 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" - )); + 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")); } - 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")); } } } @@ -880,6 +896,10 @@ fn validate_manifest( 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, @@ -1378,7 +1398,10 @@ fn validate_expected( let expected_gap_ids = parsed .artifacts .iter() - .filter(|(_, artifact)| artifact.role != "client" && artifact.state != "captured") + .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 { @@ -1398,7 +1421,9 @@ fn validate_expected( }); for artifact_id in &gap_ids { match parsed.artifacts.get(*artifact_id) { - Some(artifact) if artifact.state != "captured" => {} + Some(artifact) + if artifact.state != "captured" || artifact.fragment_complete != Some(true) => { + } _ => failures.push(format!( "{transaction_id} coverage gap {artifact_id} is absent or complete" )), @@ -1637,6 +1662,30 @@ fn validate_expected( .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()) @@ -1648,6 +1697,12 @@ fn validate_expected( 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 @@ -2373,6 +2428,102 @@ fn source_local_schema_identity_and_provenance_fail_closed() { ); } +#[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/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( + "sync-success", + &incomplete_required_rotation, + &incomplete_required_expected, + ) { + 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/WSUSCtrl.log"); + if mutation_was_accepted( + "rotation-boundary", + &parse_failed_valid_ccm, + &rotation_expected, + ) { + 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( + "rotation-boundary", + &cross_family_rotation, + &rotation_expected, + ) { + accepted.push("rotation split grouped different canonical log families"); + } + + assert!( + accepted.is_empty(), + "partial/malformed/rotation-family mutations were accepted: {accepted:?}" + ); +} + #[test] fn bounded_request_documentation_includes_nonphysical_manifest_coverage() { let contract = From 0144448492e3526e989eea12fdfd30b8014aa4b7 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 00:31:55 -0400 Subject: [PATCH 075/422] test(sccm): add provider admin service corpus --- .../provider_and_admin_service/README.md | 56 + .../current/AdminService.log | 3 + .../admin-service-auth-failure/expected.json | 24 + .../admin-service-auth-failure/manifest.json | 11 + .../current/AdminService.log | 5 + .../expected.json | 26 + .../manifest.json | 11 + .../current/AdminService.log | 6 + .../admin-service-success/expected.json | 27 + .../admin-service-success/manifest.json | 11 + .../current/u_ex_synthetic.log | 2 + .../current/AdminService.log | 4 + .../iis-supplemental/expected.json | 28 + .../iis-supplemental/manifest.json | 12 + .../current/AdminService.log | 2 + .../incomplete/expected.json | 23 + .../incomplete/manifest.json | 11 + .../current/AdminService.log | 2 + .../provider-local/current/Smsprov.log | 2 + .../privacy-redaction/expected.json | 43 + .../privacy-redaction/manifest.json | 15 + .../provider-local/current/Smsprov.log | 3 + .../provider-authz-denied/expected.json | 24 + .../provider-authz-denied/manifest.json | 11 + .../provider-local/current/Smsprov.log | 4 + .../provider-query-failure/expected.json | 25 + .../provider-query-failure/manifest.json | 11 + .../provider-local/current/Smsprov.log | 5 + .../provider-success/expected.json | 33 + .../provider-success/manifest.json | 11 + .../provider-local/current/Smsprov.log | 3 + .../provider-timeout/expected.json | 24 + .../provider-timeout/manifest.json | 11 + .../provider-local/current/Smsprov.log | 1 + .../provider-local/lo_/Smsprov.lo_ | 1 + .../rotation-boundary/expected.json | 14 + .../rotation-boundary/manifest.json | 12 + ...ider_and_admin_service_fixture_contract.rs | 1635 +++++++++++++++++ ...issue-332-provider-admin-service-corpus.md | 107 ++ 39 files changed, 2259 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/README.md create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/evidence/server-admin-service/admin-service-lab/current/AdminService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/evidence/server-admin-service/admin-service-lab/current/AdminService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/evidence/server-admin-service/admin-service-lab/current/AdminService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/server-admin-service-iis/admin-service-lab/current/u_ex_synthetic.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/server-admin-service/admin-service-lab/current/AdminService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/evidence/server-admin-service/admin-service-lab/current/AdminService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-admin-service/admin-service-lab/current/AdminService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-provider/provider-local/current/Smsprov.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/evidence/server-provider/provider-local/current/Smsprov.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/evidence/server-provider/provider-local/current/Smsprov.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/evidence/server-provider/provider-local/current/Smsprov.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/evidence/server-provider/provider-local/current/Smsprov.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/evidence/server-provider/provider-local/current/Smsprov.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/evidence/server-provider/provider-local/lo_/Smsprov.lo_ create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service_fixture_contract.rs create mode 100644 docs/sccm/preparation/issue-332-provider-admin-service-corpus.md 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..8d3cbd488 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/README.md @@ -0,0 +1,56 @@ +# 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-timeout` | Provider incomplete | invalid offset and no terminal outcome keep confidence low | +| `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 | +| `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. +- 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 a proposal pending reviewed #318 and #335 +interfaces. 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-auth-failure/evidence/server-admin-service/admin-service-lab/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/evidence/server-admin-service/admin-service-lab/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/server-admin-service/admin-service-lab/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..d56fa2d86 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/expected.json @@ -0,0 +1,24 @@ +{ + "contractState":"proposedPendingReviewed318And335", + "workflow":"providerAndAdminService", + "scenario":"admin-service-auth-failure", + "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], + "coverage":[{"artifactId":"admin-auth-current","state":"captured","sourceId":"server-admin-service","layer":"adminService"}], + "transactions":[ + { + "transactionId":"adminService:66666666-6666-6666-6666-666666666666:safe-operation-admin-auth:admin-service-lab", + "layer":"adminService", + "key":{"requestId":"66666666-6666-6666-6666-666666666666","operationHandle":"safe-operation-admin-auth","endpointId":"admin-service-lab","confidence":"exact","extractionProfileId":"admin-service-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","state":"failed","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], + "publicSummary":"Admin Service authentication was explicitly rejected.", + "observations":[ + {"observationId":"admin-auth-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-auth-current","startLine":1,"endLine":1}]}, + {"observationId":"admin-auth-02-rejected","phase":"authenticateOrAuthorize","disposition":"failed","terminal":false,"evidence":[{"artifactId":"admin-auth-current","startLine":2,"endLine":2}]}, + {"observationId":"admin-auth-03-outcome","phase":"recordOutcome","disposition":"failed","terminal":true,"evidence":[{"artifactId":"admin-auth-current","startLine":3,"endLine":3}]} + ] + } + ], + "sourceLocalObservations":[], + "artifactRequests":[], + "crossSideCausalClaims":[] +} 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..c1769bbbd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"admin-service-auth-failure", + "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T22:10:00Z"}, + "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"admin-service-lab","layer":"adminService","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, + "artifacts":[ + {"artifactId":"admin-auth-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:admin-auth-current","rotation":{"kind":"current","lineageId":"admin-auth","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:10:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":1248,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/evidence/server-admin-service/admin-service-lab/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/evidence/server-admin-service/admin-service-lab/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/server-admin-service/admin-service-lab/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..439146651 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/expected.json @@ -0,0 +1,26 @@ +{ + "contractState":"proposedPendingReviewed318And335", + "workflow":"providerAndAdminService", + "scenario":"admin-service-backend-failure", + "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], + "coverage":[{"artifactId":"admin-backend-current","state":"captured","sourceId":"server-admin-service","layer":"adminService"}], + "transactions":[ + { + "transactionId":"adminService:77777777-7777-7777-7777-777777777777:safe-operation-admin-backend:admin-service-lab", + "layer":"adminService", + "key":{"requestId":"77777777-7777-7777-7777-777777777777","operationHandle":"safe-operation-admin-backend","endpointId":"admin-service-lab","confidence":"exact","extractionProfileId":"admin-service-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","state":"failed","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], + "publicSummary":"Admin Service recorded an explicit backend operation failure.", + "observations":[ + {"observationId":"admin-backend-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-backend-current","startLine":1,"endLine":1}]}, + {"observationId":"admin-backend-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-backend-current","startLine":2,"endLine":2}]}, + {"observationId":"admin-backend-03-route","phase":"route","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-backend-current","startLine":3,"endLine":3}]}, + {"observationId":"admin-backend-04-execute","phase":"executeBackendOperation","disposition":"failed","terminal":false,"evidence":[{"artifactId":"admin-backend-current","startLine":4,"endLine":4}]}, + {"observationId":"admin-backend-05-outcome","phase":"recordOutcome","disposition":"failed","terminal":true,"evidence":[{"artifactId":"admin-backend-current","startLine":5,"endLine":5}]} + ] + } + ], + "sourceLocalObservations":[], + "artifactRequests":[], + "crossSideCausalClaims":[] +} 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..699ff8944 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"admin-service-backend-failure", + "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T22:20:00Z"}, + "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"admin-service-lab","layer":"adminService","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, + "artifacts":[ + {"artifactId":"admin-backend-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:admin-backend-current","rotation":{"kind":"current","lineageId":"admin-backend","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:20:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":2099,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/evidence/server-admin-service/admin-service-lab/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/evidence/server-admin-service/admin-service-lab/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/server-admin-service/admin-service-lab/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..110e11093 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/expected.json @@ -0,0 +1,27 @@ +{ + "contractState":"proposedPendingReviewed318And335", + "workflow":"providerAndAdminService", + "scenario":"admin-service-success", + "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], + "coverage":[{"artifactId":"admin-success-current","state":"captured","sourceId":"server-admin-service","layer":"adminService"}], + "transactions":[ + { + "transactionId":"adminService:55555555-5555-5555-5555-555555555555:safe-operation-admin-read:admin-service-lab", + "layer":"adminService", + "key":{"requestId":"55555555-5555-5555-5555-555555555555","operationHandle":"safe-operation-admin-read","endpointId":"admin-service-lab","confidence":"exact","extractionProfileId":"admin-service-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","state":"succeeded","classification":"success","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], + "publicSummary":"Admin Service request completed with explicit terminal evidence.", + "observations":[ + {"observationId":"admin-success-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-success-current","startLine":1,"endLine":1}]}, + {"observationId":"admin-success-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-success-current","startLine":2,"endLine":2}]}, + {"observationId":"admin-success-03-route","phase":"route","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-success-current","startLine":3,"endLine":3}]}, + {"observationId":"admin-success-04-backend","phase":"executeBackendOperation","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-success-current","startLine":4,"endLine":4}]}, + {"observationId":"admin-success-05-respond","phase":"respond","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-success-current","startLine":5,"endLine":5}]}, + {"observationId":"admin-success-06-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"admin-success-current","startLine":6,"endLine":6}]} + ] + } + ], + "sourceLocalObservations":[], + "artifactRequests":[], + "crossSideCausalClaims":[] +} 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..d85d541ad --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"admin-service-success", + "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T22:00:00Z"}, + "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"admin-service-lab","layer":"adminService","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, + "artifacts":[ + {"artifactId":"admin-success-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:admin-success-current","rotation":{"kind":"current","lineageId":"admin-success","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":2501,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/server-admin-service-iis/admin-service-lab/current/u_ex_synthetic.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/server-admin-service-iis/admin-service-lab/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/server-admin-service-iis/admin-service-lab/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/server-admin-service/admin-service-lab/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/server-admin-service/admin-service-lab/current/AdminService.log new file mode 100644 index 000000000..6a58ffe76 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/server-admin-service/admin-service-lab/current/AdminService.log @@ -0,0 +1,4 @@ + + + + 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..dd96a00c2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/expected.json @@ -0,0 +1,28 @@ +{ + "contractState":"proposedPendingReviewed318And335", + "workflow":"providerAndAdminService", + "scenario":"iis-supplemental", + "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], + "coverage":[ + {"artifactId":"admin-iis-current","state":"captured","sourceId":"server-admin-service","layer":"adminService"}, + {"artifactId":"iis-supplemental-current","state":"captured","sourceId":"server-admin-service-iis","layer":"supplementalIis"} + ], + "transactions":[ + { + "transactionId":"adminService:88888888-8888-8888-8888-888888888888:safe-operation-admin-iis:admin-service-lab", + "layer":"adminService", + "key":{"requestId":"88888888-8888-8888-8888-888888888888","operationHandle":"safe-operation-admin-iis","endpointId":"admin-service-lab","confidence":"exact","extractionProfileId":"admin-service-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","state":"succeeded","classification":"success","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], + "publicSummary":"Admin Service evidence independently records a terminal success.", + "observations":[ + {"observationId":"admin-iis-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":1,"endLine":1}]}, + {"observationId":"admin-iis-02-route","phase":"route","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":2,"endLine":2}]}, + {"observationId":"admin-iis-03-respond","phase":"respond","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":3,"endLine":3}]}, + {"observationId":"admin-iis-04-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"admin-iis-current","startLine":4,"endLine":4}]} + ] + } + ], + "sourceLocalObservations":[{"observationId":"iis-supplemental-01","kind":"supplementalOnly","layer":"supplementalIis","reason":"Scoped IIS evidence is optional context and cannot create an Admin Service transaction.","correlationEligible":false,"evidence":[{"artifactId":"iis-supplemental-current","startLine":2,"endLine":2}]}], + "artifactRequests":[], + "crossSideCausalClaims":[] +} 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..28fbc8041 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"iis-supplemental", + "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T22:30:00Z"}, + "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"admin-service-lab","layer":"adminService","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, + "artifacts":[ + {"artifactId":"admin-iis-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:admin-iis-current","rotation":{"kind":"current","lineageId":"admin-iis","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:30:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":1643,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"}, + {"artifactId":"iis-supplemental-current","sourceId":"server-admin-service-iis","producerRole":"provider","layer":"supplementalIis","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"supplementalOnly","originalBasename":"u_ex_synthetic.log","sanitizedSourcePath":"SYNTHETIC://scoped-export/LAB/IIS/u_ex_synthetic.log","pathFingerprint":"synthetic:iis-supplemental-current","rotation":{"kind":"current","lineageId":"iis-supplemental","fragmentComplete":true},"captureState":"captured","sourceVersion":"IIS.TEST.0001","collectedUtc":"2026-07-30T22:30:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":157,"relativePath":"evidence/server-admin-service-iis/admin-service-lab/current/u_ex_synthetic.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/evidence/server-admin-service/admin-service-lab/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/evidence/server-admin-service/admin-service-lab/current/AdminService.log new file mode 100644 index 000000000..94f8eb8be --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/evidence/server-admin-service/admin-service-lab/current/AdminService.log @@ -0,0 +1,2 @@ + + 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..153be06c6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/expected.json @@ -0,0 +1,23 @@ +{ + "contractState":"proposedPendingReviewed318And335", + "workflow":"providerAndAdminService", + "scenario":"incomplete", + "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], + "coverage":[{"artifactId":"incomplete-admin-current","state":"captured","sourceId":"server-admin-service","layer":"adminService"}], + "transactions":[ + { + "transactionId":"adminService:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:safe-operation-admin-incomplete:admin-service-lab", + "layer":"adminService", + "key":{"requestId":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","operationHandle":"safe-operation-admin-incomplete","endpointId":"admin-service-lab","confidence":"exact","extractionProfileId":"admin-service-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","state":"incomplete","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low","terminalEvidence":false,"coverageGapArtifactIds":[], + "publicSummary":"Admin Service evidence stops before an explicit response or terminal outcome.", + "observations":[ + {"observationId":"incomplete-admin-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"incomplete-admin-current","startLine":1,"endLine":1}]}, + {"observationId":"incomplete-admin-02-route","phase":"route","disposition":"pending","terminal":false,"evidence":[{"artifactId":"incomplete-admin-current","startLine":2,"endLine":2}]} + ] + } + ], + "sourceLocalObservations":[], + "artifactRequests":[{"logicalArtifactId":"server-admin-service","reason":"Capture the bounded Admin Service source lineage for the exact request key and terminal outcome."}], + "crossSideCausalClaims":[] +} 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..3bbc3c972 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"incomplete", + "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T23:00:00Z"}, + "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"admin-service-lab","layer":"adminService","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, + "artifacts":[ + {"artifactId":"incomplete-admin-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:incomplete-admin-current","rotation":{"kind":"current","lineageId":"admin-incomplete","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T23:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":830,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-admin-service/admin-service-lab/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-admin-service/admin-service-lab/current/AdminService.log new file mode 100644 index 000000000..14e75114e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-admin-service/admin-service-lab/current/AdminService.log @@ -0,0 +1,2 @@ + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-provider/provider-local/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-provider/provider-local/current/Smsprov.log new file mode 100644 index 000000000..6e828f856 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-provider/provider-local/current/Smsprov.log @@ -0,0 +1,2 @@ + + 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..db90a48be --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/expected.json @@ -0,0 +1,43 @@ +{ + "contractState":"proposedPendingReviewed318And335", + "workflow":"providerAndAdminService", + "scenario":"privacy-redaction", + "profiles":[ + {"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}, + {"layer":"provider","selectionState":"selectedSynthetic","profileId":"provider-server-5.00.test-v1"} + ], + "coverage":[ + {"artifactId":"privacy-admin-current","state":"captured","sourceId":"server-admin-service","layer":"adminService"}, + {"artifactId":"privacy-provider-current","state":"captured","sourceId":"server-provider","layer":"provider"} + ], + "transactions":[ + { + "transactionId":"adminService:99999999-9999-9999-9999-999999999999:safe-operation-admin-privacy:admin-service-lab", + "layer":"adminService", + "key":{"requestId":"99999999-9999-9999-9999-999999999999","operationHandle":"safe-operation-admin-privacy","endpointId":"admin-service-lab","confidence":"exact","extractionProfileId":"admin-service-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","state":"succeeded","classification":"success","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], + "publicSummary":"Admin Service privacy fixture completed with redacted public evidence.", + "observations":[ + {"observationId":"privacy-admin-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-admin-current","startLine":1,"endLine":1}]}, + {"observationId":"privacy-admin-02-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"privacy-admin-current","startLine":2,"endLine":2}]} + ] + }, + { + "transactionId":"provider:99999999-9999-9999-9999-999999999999:safe-operation-provider-privacy:provider-local", + "layer":"provider", + "key":{"requestId":"99999999-9999-9999-9999-999999999999","operationHandle":"safe-operation-provider-privacy","endpointId":"provider-local","confidence":"exact","extractionProfileId":"provider-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","state":"succeeded","classification":"success","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], + "publicSummary":"Provider privacy fixture completed with redacted public evidence.", + "observations":[ + {"observationId":"privacy-provider-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-provider-current","startLine":1,"endLine":1}]}, + {"observationId":"privacy-provider-02-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"privacy-provider-current","startLine":2,"endLine":2}]} + ] + } + ], + "sourceLocalObservations":[ + {"observationId":"privacy-redaction-admin","kind":"privacyRedacted","layer":"adminService","reason":"Caller and endpoint details remain outside public keys and summaries.","correlationEligible":false,"evidence":[{"artifactId":"privacy-admin-current","startLine":1,"endLine":1}]}, + {"observationId":"privacy-redaction-provider","kind":"privacyRedacted","layer":"provider","reason":"Caller, authorization, and query details remain outside public keys and summaries.","correlationEligible":false,"evidence":[{"artifactId":"privacy-provider-current","startLine":1,"endLine":1}]} + ], + "artifactRequests":[], + "crossSideCausalClaims":[] +} 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..3a3de44bd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/manifest.json @@ -0,0 +1,15 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"privacy-redaction", + "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T22:40:00Z"}, + "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[ + {"endpointId":"admin-service-lab","layer":"adminService","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}, + {"endpointId":"provider-local","layer":"provider","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"} + ]}, + "artifacts":[ + {"artifactId":"privacy-admin-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:privacy-admin-current","rotation":{"kind":"current","lineageId":"privacy-admin","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:40:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":917,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"}, + {"artifactId":"privacy-provider-current","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.log","pathFingerprint":"synthetic:privacy-provider-current","rotation":{"kind":"current","lineageId":"privacy-provider","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:40:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":945,"relativePath":"evidence/server-provider/provider-local/current/Smsprov.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/evidence/server-provider/provider-local/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/evidence/server-provider/provider-local/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/server-provider/provider-local/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..fb0d1bf41 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/expected.json @@ -0,0 +1,24 @@ +{ + "contractState":"proposedPendingReviewed318And335", + "workflow":"providerAndAdminService", + "scenario":"provider-authz-denied", + "profiles":[{"layer":"provider","selectionState":"selectedSynthetic","profileId":"provider-server-5.00.test-v1"}], + "coverage":[{"artifactId":"provider-authz-current","state":"captured","sourceId":"server-provider","layer":"provider"}], + "transactions":[ + { + "transactionId":"provider:22222222-2222-2222-2222-222222222222:safe-operation-update-device:provider-local", + "layer":"provider", + "key":{"requestId":"22222222-2222-2222-2222-222222222222","operationHandle":"safe-operation-update-device","endpointId":"provider-local","confidence":"exact","extractionProfileId":"provider-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","state":"failed","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], + "publicSummary":"Provider authorization was explicitly denied.", + "observations":[ + {"observationId":"provider-authz-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-authz-current","startLine":1,"endLine":1}]}, + {"observationId":"provider-authz-02-denied","phase":"authenticateOrAuthorize","disposition":"failed","terminal":false,"evidence":[{"artifactId":"provider-authz-current","startLine":2,"endLine":2}]}, + {"observationId":"provider-authz-03-outcome","phase":"recordOutcome","disposition":"failed","terminal":true,"evidence":[{"artifactId":"provider-authz-current","startLine":3,"endLine":3}]} + ] + } + ], + "sourceLocalObservations":[], + "artifactRequests":[], + "crossSideCausalClaims":[] +} 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..92ee4cac7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "provider-authz-denied", + "bundle": {"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T21:10:00Z"}, + "topology": {"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"provider-local","layer":"provider","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, + "artifacts": [ + {"artifactId":"provider-authz-current","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.log","pathFingerprint":"synthetic:provider-authz-current","rotation":{"kind":"current","lineageId":"provider-authz","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T21:10:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":1203,"relativePath":"evidence/server-provider/provider-local/current/Smsprov.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/evidence/server-provider/provider-local/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/evidence/server-provider/provider-local/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/server-provider/provider-local/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..bfac6273d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/expected.json @@ -0,0 +1,25 @@ +{ + "contractState":"proposedPendingReviewed318And335", + "workflow":"providerAndAdminService", + "scenario":"provider-query-failure", + "profiles":[{"layer":"provider","selectionState":"selectedSynthetic","profileId":"provider-server-5.00.test-v1"}], + "coverage":[{"artifactId":"provider-query-current","state":"captured","sourceId":"server-provider","layer":"provider"}], + "transactions":[ + { + "transactionId":"provider:33333333-3333-3333-3333-333333333333:safe-operation-query-device:provider-local", + "layer":"provider", + "key":{"requestId":"33333333-3333-3333-3333-333333333333","operationHandle":"safe-operation-query-device","endpointId":"provider-local","confidence":"exact","extractionProfileId":"provider-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"usable","state":"failed","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], + "publicSummary":"Provider operation recorded an explicit terminal failure.", + "observations":[ + {"observationId":"provider-query-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-query-current","startLine":1,"endLine":1}]}, + {"observationId":"provider-query-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-query-current","startLine":2,"endLine":2}]}, + {"observationId":"provider-query-03-execute","phase":"executeProviderOperation","disposition":"failed","terminal":false,"evidence":[{"artifactId":"provider-query-current","startLine":3,"endLine":3}]}, + {"observationId":"provider-query-04-outcome","phase":"recordOutcome","disposition":"failed","terminal":true,"evidence":[{"artifactId":"provider-query-current","startLine":4,"endLine":4}]} + ] + } + ], + "sourceLocalObservations":[], + "artifactRequests":[], + "crossSideCausalClaims":[] +} 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..47da9a4b7 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"provider-query-failure", + "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T21:20:00Z"}, + "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"provider-local","layer":"provider","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, + "artifacts":[ + {"artifactId":"provider-query-current","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.log","pathFingerprint":"synthetic:provider-query-current","rotation":{"kind":"current","lineageId":"provider-query","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T21:20:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":1612,"relativePath":"evidence/server-provider/provider-local/current/Smsprov.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/evidence/server-provider/provider-local/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/evidence/server-provider/provider-local/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/server-provider/provider-local/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..100075b77 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/expected.json @@ -0,0 +1,33 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "providerAndAdminService", + "scenario": "provider-success", + "profiles": [{"layer":"provider","selectionState":"selectedSynthetic","profileId":"provider-server-5.00.test-v1"}], + "coverage": [{"artifactId":"provider-success-current","state":"captured","sourceId":"server-provider","layer":"provider"}], + "transactions": [ + { + "transactionId":"provider:11111111-1111-1111-1111-111111111111:safe-operation-read-device:provider-local", + "layer":"provider", + "key":{"requestId":"11111111-1111-1111-1111-111111111111","operationHandle":"safe-operation-read-device","endpointId":"provider-local","confidence":"exact","extractionProfileId":"provider-server-5.00.test-v1"}, + "topologyCompatibility":"exact", + "timestampOrdering":"usable", + "state":"succeeded", + "classification":"success", + "confidence":"high", + "confidenceCeiling":"high", + "terminalEvidence":true, + "coverageGapArtifactIds":[], + "publicSummary":"Provider operation completed with explicit terminal evidence.", + "observations":[ + {"observationId":"provider-success-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-success-current","startLine":1,"endLine":1}]}, + {"observationId":"provider-success-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-success-current","startLine":2,"endLine":2}]}, + {"observationId":"provider-success-03-execute","phase":"executeProviderOperation","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-success-current","startLine":3,"endLine":3}]}, + {"observationId":"provider-success-04-respond","phase":"respond","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-success-current","startLine":4,"endLine":4}]}, + {"observationId":"provider-success-05-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"provider-success-current","startLine":5,"endLine":5}]} + ] + } + ], + "sourceLocalObservations": [], + "artifactRequests": [], + "crossSideCausalClaims": [] +} 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..71dea661f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "provider-success", + "bundle": {"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T21:00:00Z"}, + "topology": {"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"provider-local","layer":"provider","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, + "artifacts": [ + {"artifactId":"provider-success-current","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.log","pathFingerprint":"synthetic:provider-success-current","rotation":{"kind":"current","lineageId":"provider-success","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T21:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":2008,"relativePath":"evidence/server-provider/provider-local/current/Smsprov.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/evidence/server-provider/provider-local/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/evidence/server-provider/provider-local/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/server-provider/provider-local/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..0975dcecd --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/expected.json @@ -0,0 +1,24 @@ +{ + "contractState":"proposedPendingReviewed318And335", + "workflow":"providerAndAdminService", + "scenario":"provider-timeout", + "profiles":[{"layer":"provider","selectionState":"selectedSynthetic","profileId":"provider-server-5.00.test-v1"}], + "coverage":[{"artifactId":"provider-timeout-current","state":"captured","sourceId":"server-provider","layer":"provider"}], + "transactions":[ + { + "transactionId":"provider:44444444-4444-4444-4444-444444444444:safe-operation-provider-timeout:provider-local", + "layer":"provider", + "key":{"requestId":"44444444-4444-4444-4444-444444444444","operationHandle":"safe-operation-provider-timeout","endpointId":"provider-local","confidence":"exact","extractionProfileId":"provider-server-5.00.test-v1"}, + "topologyCompatibility":"exact","timestampOrdering":"unusableInvalidOffset","state":"incomplete","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low","terminalEvidence":false,"coverageGapArtifactIds":[], + "publicSummary":"Provider evidence stops before an explicit terminal outcome.", + "observations":[ + {"observationId":"provider-timeout-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-timeout-current","startLine":1,"endLine":1}]}, + {"observationId":"provider-timeout-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-timeout-current","startLine":2,"endLine":2}]}, + {"observationId":"provider-timeout-03-execute","phase":"executeProviderOperation","disposition":"pending","terminal":false,"evidence":[{"artifactId":"provider-timeout-current","startLine":3,"endLine":3}]} + ] + } + ], + "sourceLocalObservations":[], + "artifactRequests":[{"logicalArtifactId":"server-provider","reason":"Capture the bounded Provider source lineage for the exact request key and terminal outcome."}], + "crossSideCausalClaims":[] +} 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..acd52256b --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/manifest.json @@ -0,0 +1,11 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"provider-timeout", + "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T21:30:00Z"}, + "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"provider-local","layer":"provider","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, + "artifacts":[ + {"artifactId":"provider-timeout-current","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.log","pathFingerprint":"synthetic:provider-timeout-current","rotation":{"kind":"current","lineageId":"provider-timeout","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T21:30:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":1234,"relativePath":"evidence/server-provider/provider-local/current/Smsprov.log"} + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/evidence/server-provider/provider-local/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/evidence/server-provider/provider-local/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/server-provider/provider-local/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..dfbde107c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/expected.json @@ -0,0 +1,14 @@ +{ + "contractState":"proposedPendingReviewed318And335", + "workflow":"providerAndAdminService", + "scenario":"rotation-boundary", + "profiles":[{"layer":"provider","selectionState":"unknownVersion"}], + "coverage":[ + {"artifactId":"rotation-01-current","state":"captured","sourceId":"server-provider","layer":"provider"}, + {"artifactId":"rotation-02-lo","state":"captured","sourceId":"server-provider","layer":"provider"} + ], + "transactions":[], + "sourceLocalObservations":[{"observationId":"rotation-fragment-01","kind":"rotationFragment","layer":"provider","reason":"Split rotation fragments and an unknown version cannot form an exact request key.","correlationEligible":false,"evidence":[{"artifactId":"rotation-01-current","startLine":1,"endLine":1},{"artifactId":"rotation-02-lo","startLine":1,"endLine":1}]}], + "artifactRequests":[{"logicalArtifactId":"server-provider","reason":"Capture one bounded complete Provider rotation with known version provenance."}], + "crossSideCausalClaims":[] +} 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..e62f80740 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/manifest.json @@ -0,0 +1,12 @@ +{ + "sccmManifestVersion":1, + "proposalOnly":true, + "syntheticFixture":true, + "scenario":"rotation-boundary", + "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T22:50:00Z"}, + "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"provider-local","layer":"provider","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, + "artifacts":[ + {"artifactId":"rotation-01-current","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.log","pathFingerprint":"synthetic:rotation-current","rotation":{"kind":"current","lineageId":"provider-rotation","fragmentComplete":false},"captureState":"captured","sourceVersion":"9.99.UNKNOWN","collectedUtc":"2026-07-30T22:50:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":127,"relativePath":"evidence/server-provider/provider-local/current/Smsprov.log"}, + {"artifactId":"rotation-02-lo","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.lo_","pathFingerprint":"synthetic:rotation-lo","rotation":{"kind":"lo_","lineageId":"provider-rotation","fragmentComplete":false},"captureState":"captured","sourceVersion":"9.99.UNKNOWN","collectedUtc":"2026-07-30T22:50:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":295,"relativePath":"evidence/server-provider/provider-local/lo_/Smsprov.lo_"} + ] +} 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..0f81c1d96 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service_fixture_contract.rs @@ -0,0 +1,1635 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::PathBuf; + +use chrono::DateTime; +use cmtraceopen_parser::sccm::{ + classify_artifact_name, normalize_ccm_artifact, SccmArtifact, SccmArtifactFamily, + SccmCoverageState, SccmEvidence, SccmRole, SccmRotation, SccmTimeOrderingState, +}; +use serde_json::Value; + +const SCENARIOS: [&str; 11] = [ + "admin-service-auth-failure", + "admin-service-backend-failure", + "admin-service-success", + "iis-supplemental", + "incomplete", + "privacy-redaction", + "provider-authz-denied", + "provider-query-failure", + "provider-success", + "provider-timeout", + "rotation-boundary", +]; + +const PROVIDER_CHAIN: [&str; 5] = [ + "receive", + "authenticateOrAuthorize", + "executeProviderOperation", + "respond", + "recordOutcome", +]; + +const ADMIN_SERVICE_CHAIN: [&str; 6] = [ + "receive", + "authenticateOrAuthorize", + "route", + "executeBackendOperation", + "respond", + "recordOutcome", +]; + +const PROVIDER_PROFILE: &str = "provider-server-5.00.test-v1"; +const ADMIN_SERVICE_PROFILE: &str = "admin-service-server-5.00.test-v1"; + +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) -> Result { + let path = corpus_root().join(scenario).join(filename); + let contents = 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 scenarios = 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::>(); + Ok(scenarios) +} + +fn safe_segmented_path(value: &str, prefix: &str) -> bool { + value.strip_prefix(prefix).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 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), + "lo_" 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 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 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", + "RequestId", + "OperationHandle", + "EndpointId", + "Layer", + "ProfileId", + "CallerHandle", + "QueryHandle", + "Authorization", + ]; + let mut fields = BTreeMap::new(); + for segment in segments { + if segment.starts_with("[redacted:") && segment.ends_with(']') { + continue; + } + 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 fields.insert(name.to_owned(), value.to_owned()).is_some() { + return Err(format!("duplicate fixture field {name}")); + } + } + Ok(fields) +} + +fn normalized_records( + scenario: &str, + manifest: &Value, +) -> BTreeMap<(String, u32, u32), SccmEvidence> { + let mut records = BTreeMap::new(); + for artifact in manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + { + if artifact["diagnosticUse"] != "primary" + || !matches!( + artifact["captureState"].as_str(), + Some("captured" | "capped") + ) + { + continue; + } + let artifact_id = artifact["artifactId"] + .as_str() + .expect("artifact ID is a string"); + let relative_path = artifact["relativePath"] + .as_str() + .expect("physical artifact has a relative path"); + let content = fs::read_to_string(corpus_root().join(scenario).join(relative_path)) + .expect("fixture evidence is readable UTF-8"); + let model = SccmArtifact { + artifact_id: artifact_id.to_owned(), + display_name: artifact["originalBasename"] + .as_str() + .expect("artifact basename is a string") + .to_owned(), + original_path: None, + host: artifact["producerHostHandle"].as_str().map(str::to_owned), + role: SccmRole::Provider, + 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"]).expect("rotation is valid"), + coverage: coverage_state(artifact["captureState"].as_str().unwrap_or_default()) + .expect("coverage is valid"), + encoding: artifact["encoding"].as_str().map(str::to_owned), + }; + for record in normalize_ccm_artifact(model, &content) { + let start = record + .reference + .line_start + .expect("normalized evidence has a start line"); + let end = record + .reference + .line_end + .expect("normalized evidence has an end line"); + assert!( + records + .insert((artifact_id.to_owned(), start, end), record) + .is_none(), + "{scenario}: duplicate logical evidence" + ); + } + } + records +} + +fn expected_transaction_ids(scenario: &str) -> &'static [&'static str] { + match scenario { + "admin-service-auth-failure" => &[ + "adminService:66666666-6666-6666-6666-666666666666:safe-operation-admin-auth:admin-service-lab", + ], + "admin-service-backend-failure" => &[ + "adminService:77777777-7777-7777-7777-777777777777:safe-operation-admin-backend:admin-service-lab", + ], + "admin-service-success" => &[ + "adminService:55555555-5555-5555-5555-555555555555:safe-operation-admin-read:admin-service-lab", + ], + "iis-supplemental" => &[ + "adminService:88888888-8888-8888-8888-888888888888:safe-operation-admin-iis:admin-service-lab", + ], + "incomplete" => &[ + "adminService:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:safe-operation-admin-incomplete:admin-service-lab", + ], + "privacy-redaction" => &[ + "adminService:99999999-9999-9999-9999-999999999999:safe-operation-admin-privacy:admin-service-lab", + "provider:99999999-9999-9999-9999-999999999999:safe-operation-provider-privacy:provider-local", + ], + "provider-authz-denied" => &[ + "provider:22222222-2222-2222-2222-222222222222:safe-operation-update-device:provider-local", + ], + "provider-query-failure" => &[ + "provider:33333333-3333-3333-3333-333333333333:safe-operation-query-device:provider-local", + ], + "provider-success" => &[ + "provider:11111111-1111-1111-1111-111111111111:safe-operation-read-device:provider-local", + ], + "provider-timeout" => &[ + "provider:44444444-4444-4444-4444-444444444444:safe-operation-provider-timeout:provider-local", + ], + "rotation-boundary" => &[], + _ => &[], + } +} + +fn expected_outcomes(scenario: &str) -> &'static [(&'static str, &'static str, &'static str)] { + match scenario { + "admin-service-auth-failure" | "admin-service-backend-failure" => { + &[("failed", "confirmedFailure", "high")] + } + "admin-service-success" | "iis-supplemental" | "provider-success" => { + &[("succeeded", "success", "high")] + } + "privacy-redaction" => &[ + ("succeeded", "success", "high"), + ("succeeded", "success", "high"), + ], + "provider-authz-denied" | "provider-query-failure" => { + &[("failed", "confirmedFailure", "high")] + } + "provider-timeout" | "incomplete" => &[("incomplete", "insufficientEvidence", "low")], + "rotation-boundary" => &[], + _ => &[], + } +} + +fn expected_artifact_ids(scenario: &str) -> &'static [&'static str] { + match scenario { + "admin-service-auth-failure" => &["admin-auth-current"], + "admin-service-backend-failure" => &["admin-backend-current"], + "admin-service-success" => &["admin-success-current"], + "iis-supplemental" => &["admin-iis-current", "iis-supplemental-current"], + "incomplete" => &["incomplete-admin-current"], + "privacy-redaction" => &["privacy-admin-current", "privacy-provider-current"], + "provider-authz-denied" => &["provider-authz-current"], + "provider-query-failure" => &["provider-query-current"], + "provider-success" => &["provider-success-current"], + "provider-timeout" => &["provider-timeout-current"], + "rotation-boundary" => &["rotation-01-current", "rotation-02-lo"], + _ => &[], + } +} + +fn expected_observation_ids(scenario: &str) -> &'static [&'static str] { + match scenario { + "admin-service-auth-failure" => &[ + "admin-auth-01-receive", + "admin-auth-02-rejected", + "admin-auth-03-outcome", + ], + "admin-service-backend-failure" => &[ + "admin-backend-01-receive", + "admin-backend-02-authorize", + "admin-backend-03-route", + "admin-backend-04-execute", + "admin-backend-05-outcome", + ], + "admin-service-success" => &[ + "admin-success-01-receive", + "admin-success-02-authorize", + "admin-success-03-route", + "admin-success-04-backend", + "admin-success-05-respond", + "admin-success-06-outcome", + ], + "iis-supplemental" => &[ + "admin-iis-01-receive", + "admin-iis-02-route", + "admin-iis-03-respond", + "admin-iis-04-outcome", + ], + "incomplete" => &["incomplete-admin-01-receive", "incomplete-admin-02-route"], + "privacy-redaction" => &[ + "privacy-admin-01-receive", + "privacy-admin-02-outcome", + "privacy-provider-01-receive", + "privacy-provider-02-outcome", + ], + "provider-authz-denied" => &[ + "provider-authz-01-receive", + "provider-authz-02-denied", + "provider-authz-03-outcome", + ], + "provider-query-failure" => &[ + "provider-query-01-receive", + "provider-query-02-authorize", + "provider-query-03-execute", + "provider-query-04-outcome", + ], + "provider-success" => &[ + "provider-success-01-receive", + "provider-success-02-authorize", + "provider-success-03-execute", + "provider-success-04-respond", + "provider-success-05-outcome", + ], + "provider-timeout" => &[ + "provider-timeout-01-receive", + "provider-timeout-02-authorize", + "provider-timeout-03-execute", + ], + "rotation-boundary" => &[], + _ => &[], + } +} + +fn expected_source_local_ids(scenario: &str) -> &'static [&'static str] { + match scenario { + "iis-supplemental" => &["iis-supplemental-01"], + "privacy-redaction" => &["privacy-redaction-admin", "privacy-redaction-provider"], + "rotation-boundary" => &["rotation-fragment-01"], + _ => &[], + } +} + +fn expected_requested_sources(scenario: &str) -> &'static [&'static str] { + match scenario { + "incomplete" => &["server-admin-service"], + "provider-timeout" | "rotation-boundary" => &["server-provider"], + _ => &[], + } +} + +fn expected_profile_layers(scenario: &str) -> &'static [&'static str] { + match scenario { + "admin-service-auth-failure" + | "admin-service-backend-failure" + | "admin-service-success" + | "iis-supplemental" + | "incomplete" => &["adminService"], + "privacy-redaction" => &["adminService", "provider"], + "provider-authz-denied" + | "provider-query-failure" + | "provider-success" + | "provider-timeout" + | "rotation-boundary" => &["provider"], + _ => &[], + } +} + +fn known_test_version(value: &str) -> bool { + value.strip_prefix("5.00.TEST.").is_some_and(|suffix| { + !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +fn safe_version(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'.') +} + +fn state_chain(layer: &str) -> Option<&'static [&'static str]> { + match layer { + "provider" => Some(&PROVIDER_CHAIN), + "adminService" => Some(&ADMIN_SERVICE_CHAIN), + _ => None, + } +} + +fn profile_for(layer: &str) -> Option<&'static str> { + match layer { + "provider" => Some(PROVIDER_PROFILE), + "adminService" => Some(ADMIN_SERVICE_PROFILE), + _ => None, + } +} + +fn 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", + ], + ) || manifest["sccmManifestVersion"] != 1 + || manifest["proposalOnly"] != true + || manifest["syntheticFixture"] != true + || manifest["scenario"] != scenario + || !object_has_only( + &manifest["bundle"], + &["bundleRole", "workflow", "capturedUtc"], + ) + || manifest["bundle"]["bundleRole"] != "server" + || manifest["bundle"]["workflow"] != "providerAndAdminService" + || DateTime::parse_from_rfc3339( + manifest["bundle"]["capturedUtc"] + .as_str() + .unwrap_or_default(), + ) + .is_err() + { + failures.push("manifest identity or bundle contract changed".to_owned()); + } + + if !object_has_only( + &manifest["topology"], + &["siteCode", "rolesObserved", "endpoints"], + ) || manifest["topology"]["siteCode"] != "LAB" + { + failures.push("manifest topology contains an unsupported shape".to_owned()); + } + let roles = manifest["topology"]["rolesObserved"].as_array(); + if roles.is_none_or(|roles| { + roles.len() != 1 || roles.iter().any(|role| role.as_str() != Some("provider")) + }) { + failures.push("topology roles are not exact strings".to_owned()); + } + + let endpoints = manifest["topology"]["endpoints"].as_array(); + let mut endpoint_layers = BTreeMap::new(); + let mut endpoint_ids = Vec::new(); + for endpoint in endpoints.into_iter().flatten() { + if !object_has_only( + endpoint, + &["endpointId", "layer", "hostHandle", "producerRole"], + ) { + failures.push("endpoint contains unsupported fields".to_owned()); + } + let Some(endpoint_id) = endpoint["endpointId"].as_str() else { + failures.push("endpoint ID is not a string".to_owned()); + continue; + }; + let Some(layer) = endpoint["layer"].as_str() else { + failures.push(format!("{endpoint_id}: endpoint layer is not a string")); + continue; + }; + if !matches!(layer, "provider" | "adminService") + || endpoint["producerRole"] != "provider" + || endpoint["hostHandle"] + .as_str() + .is_none_or(|value| !value.starts_with("safe:server:")) + || endpoint_layers + .insert(endpoint_id.to_owned(), layer.to_owned()) + .is_some() + { + failures.push(format!("{endpoint_id}: invalid endpoint topology")); + } + endpoint_ids.push(endpoint_id); + } + let mut sorted_endpoint_ids = endpoint_ids.clone(); + sorted_endpoint_ids.sort_unstable(); + sorted_endpoint_ids.dedup(); + if endpoints.is_none() + || endpoint_ids.is_empty() + || endpoint_ids != sorted_endpoint_ids + || endpoint_layers.len() != endpoint_ids.len() + { + failures.push("endpoint identities are not exact sorted unique strings".to_owned()); + } + + let artifacts = manifest["artifacts"].as_array(); + let mut artifact_ids = Vec::new(); + let mut artifact_sources = BTreeMap::new(); + let mut artifact_physical_state = BTreeMap::new(); + let mut fingerprints = BTreeSet::new(); + let mut destinations = BTreeSet::new(); + for artifact in artifacts.into_iter().flatten() { + if !object_has_only( + artifact, + &[ + "artifactId", + "sourceId", + "producerRole", + "layer", + "producerHostHandle", + "endpointId", + "diagnosticUse", + "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 unsupported fields".to_owned()); + } + let Some(artifact_id) = artifact["artifactId"].as_str() else { + failures.push("artifact ID is not a string".to_owned()); + continue; + }; + if artifact_id.is_empty() { + failures.push("artifact ID is empty".to_owned()); + } + artifact_ids.push(artifact_id); + let source_id = artifact["sourceId"].as_str(); + let layer = artifact["layer"].as_str(); + let basename = artifact["originalBasename"].as_str(); + let diagnostic_use = artifact["diagnosticUse"].as_str(); + let source_tuple = (layer, source_id, basename, diagnostic_use); + if !matches!( + source_tuple, + ( + Some("provider"), + Some("server-provider"), + Some("Smsprov.log"), + Some("primary") + ) | ( + Some("adminService"), + Some("server-admin-service"), + Some("AdminService.log"), + Some("primary") + ) | ( + Some("supplementalIis"), + Some("server-admin-service-iis"), + Some("u_ex_synthetic.log"), + Some("supplementalOnly") + ) + ) { + failures.push(format!("{artifact_id}: unsupported source/layer tuple")); + } + if artifact["producerRole"] != "provider" + || artifact["producerHostHandle"] + .as_str() + .is_none_or(|value| !value.starts_with("safe:server:")) + || DateTime::parse_from_rfc3339(artifact["collectedUtc"].as_str().unwrap_or_default()) + .is_err() + || artifact["sanitizedSourcePath"] + .as_str() + .is_none_or(|value| !safe_segmented_path(value, "SYNTHETIC://")) + || artifact["pathFingerprint"] + .as_str() + .is_none_or(|value| value.strip_prefix("synthetic:").is_none_or(str::is_empty)) + || artifact["pathFingerprint"] + .as_str() + .map(str::to_ascii_lowercase) + .is_none_or(|value| !fingerprints.insert(value)) + || rotation(&artifact["rotation"]).is_none() + || artifact["rotation"]["lineageId"] + .as_str() + .is_none_or(str::is_empty) + || coverage_state(artifact["captureState"].as_str().unwrap_or_default()).is_none() + { + failures.push(format!("{artifact_id}: invalid typed provenance")); + } + let endpoint_id = artifact["endpointId"].as_str(); + let expected_endpoint_layer = endpoint_id.and_then(|id| endpoint_layers.get(id)); + let endpoint_matches = match layer { + Some("supplementalIis") => { + expected_endpoint_layer.map(String::as_str) == Some("adminService") + } + Some(layer) => expected_endpoint_layer.map(String::as_str) == Some(layer), + None => false, + }; + if !endpoint_matches { + failures.push(format!("{artifact_id}: incompatible endpoint topology")); + } + let state = artifact["captureState"].as_str(); + match state { + Some("captured" | "capped" | "parseFailed") => { + if artifact["relativePath"] + .as_str() + .is_none_or(|value| !safe_segmented_path(value, "evidence/")) + || artifact["relativePath"] + .as_str() + .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() + || artifact["sourceVersion"] + .as_str() + .is_none_or(|value| !safe_version(value)) + { + failures.push(format!("{artifact_id}: invalid physical provenance")); + } + } + 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 coverage invents file provenance" + )); + } + } + _ => {} + } + if let (Some(source_id), Some(layer)) = (source_id, layer) { + artifact_sources.insert( + artifact_id.to_owned(), + (source_id.to_owned(), layer.to_owned()), + ); + } + artifact_physical_state.insert( + artifact_id.to_owned(), + ( + state.unwrap_or_default().to_owned(), + artifact["rotation"]["fragmentComplete"].as_bool(), + ), + ); + } + let mut sorted_artifact_ids = artifact_ids.clone(); + sorted_artifact_ids.sort_unstable(); + sorted_artifact_ids.dedup(); + if artifacts.is_none() + || artifact_ids.is_empty() + || artifact_ids != sorted_artifact_ids + || artifact_ids != expected_artifact_ids(scenario) + || artifact_sources.len() != artifact_ids.len() + { + failures.push("artifact IDs are not exact sorted unique strings".to_owned()); + } + + if !object_has_only( + expected, + &[ + "contractState", + "workflow", + "scenario", + "profiles", + "coverage", + "transactions", + "sourceLocalObservations", + "artifactRequests", + "crossSideCausalClaims", + ], + ) || expected["contractState"] != "proposedPendingReviewed318And335" + || expected["workflow"] != "providerAndAdminService" + || expected["scenario"] != scenario + || expected["crossSideCausalClaims"] != Value::Array(Vec::new()) + { + failures.push("expected output loses its preparation boundary".to_owned()); + } + + let profiles = expected["profiles"].as_array(); + let mut profile_layers = Vec::new(); + for profile in profiles.into_iter().flatten() { + if !object_has_only(profile, &["layer", "selectionState", "profileId"]) { + failures.push("profile contains unsupported fields".to_owned()); + } + let layer = profile["layer"].as_str(); + let selection = profile["selectionState"].as_str(); + let profile_id = profile["profileId"].as_str(); + let versions = manifest["artifacts"] + .as_array() + .into_iter() + .flatten() + .filter(|artifact| { + artifact["layer"].as_str() == layer && artifact["diagnosticUse"] == "primary" + }) + .filter_map(|artifact| artifact["sourceVersion"].as_str()) + .collect::>(); + let versions_are_known = + !versions.is_empty() && versions.iter().all(|version| known_test_version(version)); + let versions_are_unknown = !versions.is_empty() && !versions_are_known; + if layer.is_none() + || !matches!(selection, Some("selectedSynthetic" | "unknownVersion")) + || match selection { + Some("selectedSynthetic") => { + layer.and_then(profile_for) != profile_id || !versions_are_known + } + Some("unknownVersion") => profile_id.is_some() || !versions_are_unknown, + _ => true, + } + { + failures.push("profile selection is not exact and versioned".to_owned()); + } + if let Some(layer) = layer { + profile_layers.push(layer); + } + } + let mut sorted_profile_layers = profile_layers.clone(); + sorted_profile_layers.sort_unstable(); + sorted_profile_layers.dedup(); + if profiles.is_none() + || profile_layers.is_empty() + || profile_layers != sorted_profile_layers + || profile_layers != expected_profile_layers(scenario) + || profile_layers + .iter() + .any(|layer| !matches!(*layer, "provider" | "adminService" | "supplementalIis")) + { + failures.push("profile layers are not exact sorted unique strings".to_owned()); + } + + let coverage = expected["coverage"].as_array(); + let mut coverage_projection = Vec::new(); + for row in coverage.into_iter().flatten() { + if !object_has_only(row, &["artifactId", "state", "sourceId", "layer"]) { + failures.push("coverage row contains unsupported fields".to_owned()); + } + let artifact_id = row["artifactId"].as_str(); + let state = row["state"].as_str(); + let source_id = row["sourceId"].as_str(); + let layer = row["layer"].as_str(); + if artifact_id.is_none() + || state.and_then(coverage_state).is_none() + || artifact_id + .and_then(|id| artifact_sources.get(id)) + .is_none_or(|(expected_source, expected_layer)| { + Some(expected_source.as_str()) != source_id + || Some(expected_layer.as_str()) != layer + }) + { + failures.push("coverage row is not bound to a manifest artifact".to_owned()); + } + if let Some(artifact_id) = artifact_id { + coverage_projection.push(artifact_id); + } + } + let manifest_coverage = manifest["artifacts"] + .as_array() + .into_iter() + .flatten() + .filter_map(|artifact| { + Some(( + artifact["artifactId"].as_str()?, + artifact["captureState"].as_str()?, + )) + }) + .collect::>(); + let expected_coverage = coverage + .into_iter() + .flatten() + .filter_map(|row| Some((row["artifactId"].as_str()?, row["state"].as_str()?))) + .collect::>(); + if coverage.is_none() + || coverage_projection != artifact_ids + || manifest_coverage != expected_coverage + { + failures.push("coverage is not the exact sorted manifest projection".to_owned()); + } + + let transactions = expected["transactions"].as_array(); + let mut transaction_ids = Vec::new(); + let mut all_observation_ids = Vec::new(); + let mut outcomes = Vec::new(); + for transaction in transactions.into_iter().flatten() { + if !object_has_only( + transaction, + &[ + "transactionId", + "layer", + "key", + "topologyCompatibility", + "timestampOrdering", + "state", + "classification", + "confidence", + "confidenceCeiling", + "terminalEvidence", + "coverageGapArtifactIds", + "publicSummary", + "observations", + ], + ) || !object_has_only( + &transaction["key"], + &[ + "requestId", + "operationHandle", + "endpointId", + "confidence", + "extractionProfileId", + ], + ) { + failures.push("transaction contains unsupported fields".to_owned()); + } + let transaction_id = transaction["transactionId"].as_str().unwrap_or_default(); + transaction_ids.push(transaction_id); + outcomes.push(( + transaction["state"].as_str().unwrap_or_default(), + transaction["classification"].as_str().unwrap_or_default(), + transaction["confidence"].as_str().unwrap_or_default(), + )); + let layer = transaction["layer"].as_str().unwrap_or_default(); + let key = &transaction["key"]; + let request_id = key["requestId"].as_str(); + let operation = key["operationHandle"].as_str(); + let endpoint_id = key["endpointId"].as_str(); + let derived_id = format!( + "{layer}:{}:{}:{}", + request_id.unwrap_or_default().to_ascii_lowercase(), + operation.unwrap_or_default(), + endpoint_id.unwrap_or_default() + ); + if state_chain(layer).is_none() + || request_id.is_none_or(str::is_empty) + || operation.is_none_or(|value| !value.starts_with("safe-operation-")) + || endpoint_id.is_none_or(str::is_empty) + || key["confidence"] != "exact" + || key["extractionProfileId"].as_str() != profile_for(layer) + || transaction_id != derived_id + || endpoint_id + .and_then(|id| endpoint_layers.get(id)) + .map(String::as_str) + != Some(layer) + || transaction["topologyCompatibility"] != "exact" + || !matches!( + transaction["timestampOrdering"].as_str(), + Some("usable" | "unusableInvalidOffset") + ) + || !matches!( + transaction["confidenceCeiling"].as_str(), + Some("high" | "medium" | "low") + ) + { + failures.push("transaction identity/key/topology is not exact".to_owned()); + } + 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 + || gap_ids.iter().any(|id| !artifact_sources.contains_key(*id)) + { + failures.push("coverage gaps are not exact sorted manifest artifact IDs".to_owned()); + } + if transaction["confidence"] == "high" + && (transaction["confidenceCeiling"] != "high" + || transaction["timestampOrdering"] != "usable" + || transaction["terminalEvidence"] != true + || !gap_ids.is_empty()) + { + failures.push("high confidence bypasses time/terminal/coverage gates".to_owned()); + } + if transaction["publicSummary"].as_str().is_none_or(|summary| { + summary.is_empty() + || [ + "@", + "bearer", + "select ", + "http://", + "https://", + "/adminservice", + ] + .iter() + .any(|term| summary.to_ascii_lowercase().contains(term)) + }) { + failures.push("public transaction summary is unsafe or empty".to_owned()); + } + + let observations = transaction["observations"].as_array(); + let mut observation_ids = Vec::new(); + let mut prior_phase = 0usize; + let mut cited_terminal = false; + let mut cited_references = BTreeSet::new(); + for observation in observations.into_iter().flatten() { + if !object_has_only( + observation, + &[ + "observationId", + "phase", + "disposition", + "terminal", + "evidence", + ], + ) { + failures.push("transaction observation contains unsupported fields".to_owned()); + } + let observation_id = observation["observationId"].as_str(); + let phase = observation["phase"].as_str(); + let chain = state_chain(layer).unwrap_or_default(); + let phase_index = phase.and_then(|phase| chain.iter().position(|item| *item == phase)); + if observation_id.is_none_or(str::is_empty) + || phase_index.is_none() + || phase_index.is_some_and(|index| index < prior_phase) + || observation["disposition"] + .as_str() + .is_none_or(str::is_empty) + || observation["terminal"].as_bool().is_none() + { + failures.push("transaction observation is not exact/monotonic".to_owned()); + } + if let Some(index) = phase_index { + prior_phase = index; + } + cited_terminal |= observation["terminal"].as_bool().unwrap_or(false); + if let Some(observation_id) = observation_id { + observation_ids.push(observation_id); + all_observation_ids.push(observation_id); + } + 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"]) + || reference["artifactId"] + .as_str() + .is_none_or(|id| !artifact_sources.contains_key(id)) + || reference["artifactId"].as_str().is_some_and(|id| { + artifact_sources + .get(id) + .is_none_or(|(_, artifact_layer)| artifact_layer != layer) + }) + || reference["artifactId"].as_str().is_some_and(|id| { + artifact_physical_state + .get(id) + .is_none_or(|(state, complete)| { + state != "captured" || *complete != Some(true) + }) + }) + || reference["startLine"].as_u64().is_none_or(|line| line == 0) + || reference["endLine"] + .as_u64() + .zip(reference["startLine"].as_u64()) + .is_none_or(|(end, start)| end < start) + { + failures.push("transaction evidence reference is malformed".to_owned()); + } + if let (Some(artifact_id), Some(start), Some(end)) = ( + reference["artifactId"].as_str(), + reference["startLine"].as_u64(), + reference["endLine"].as_u64(), + ) { + if !cited_references.insert((artifact_id, start, end)) { + failures.push("transaction evidence reference is duplicated".to_owned()); + } + } + } + } + let mut sorted_observation_ids = observation_ids.clone(); + sorted_observation_ids.sort_unstable(); + sorted_observation_ids.dedup(); + if observations.is_none() + || observation_ids.is_empty() + || observation_ids != sorted_observation_ids + { + failures + .push("transaction observation identities are not sorted and unique".to_owned()); + } + if transaction["terminalEvidence"].as_bool() != Some(cited_terminal) { + failures.push("transaction terminality is not citation-derived".to_owned()); + } + } + let mut sorted_transaction_ids = transaction_ids.clone(); + sorted_transaction_ids.sort_unstable(); + sorted_transaction_ids.dedup(); + if transactions.is_none() + || transaction_ids != sorted_transaction_ids + || transaction_ids != expected_transaction_ids(scenario) + || all_observation_ids != expected_observation_ids(scenario) + || outcomes != expected_outcomes(scenario) + { + failures.push("transaction identity/cardinality matrix changed".to_owned()); + } + + let source_local = expected["sourceLocalObservations"].as_array(); + let mut source_local_ids = Vec::new(); + for observation in source_local.into_iter().flatten() { + if !object_has_only( + observation, + &[ + "observationId", + "kind", + "layer", + "reason", + "correlationEligible", + "evidence", + ], + ) || !matches!( + observation["kind"].as_str(), + Some("supplementalOnly" | "privacyRedacted" | "rotationFragment") + ) || !matches!( + observation["layer"].as_str(), + Some("provider" | "adminService" | "supplementalIis") + ) || observation["reason"].as_str().is_none_or(str::is_empty) + || observation["correlationEligible"] != false + { + failures.push("source-local observation is not closed and noncorrelatable".to_owned()); + } + if let Some(id) = observation["observationId"].as_str() { + if id.is_empty() { + failures.push("source-local observation ID is empty".to_owned()); + } + source_local_ids.push(id); + } else { + failures.push("source-local observation ID is not a string".to_owned()); + } + let references = observation["evidence"].as_array(); + if references.is_none_or(Vec::is_empty) { + failures.push("source-local observation lacks evidence".to_owned()); + } + let mut cited_references = BTreeSet::new(); + for reference in references.into_iter().flatten() { + if !object_has_only(reference, &["artifactId", "startLine", "endLine"]) + || reference["artifactId"] + .as_str() + .is_none_or(|id| !artifact_sources.contains_key(id)) + || reference["artifactId"].as_str().is_some_and(|id| { + let observation_layer = observation["layer"].as_str().unwrap_or_default(); + artifact_sources + .get(id) + .is_none_or(|(_, artifact_layer)| artifact_layer != observation_layer) + }) + || reference["startLine"].as_u64().is_none_or(|line| line == 0) + || reference["endLine"].as_u64().is_none_or(|line| line == 0) + { + failures.push("source-local evidence reference is malformed".to_owned()); + } + if let (Some(artifact_id), Some(start), Some(end)) = ( + reference["artifactId"].as_str(), + reference["startLine"].as_u64(), + reference["endLine"].as_u64(), + ) { + if !cited_references.insert((artifact_id, start, end)) { + failures.push("source-local evidence reference is duplicated".to_owned()); + } + } + } + } + let mut sorted_source_local_ids = source_local_ids.clone(); + sorted_source_local_ids.sort_unstable(); + sorted_source_local_ids.dedup(); + if source_local.is_none() + || source_local_ids != sorted_source_local_ids + || source_local_ids != expected_source_local_ids(scenario) + { + failures.push("source-local observation IDs are not sorted and unique".to_owned()); + } + + let requests = expected["artifactRequests"].as_array(); + let mut requested_sources = Vec::new(); + for request in requests.into_iter().flatten() { + if !object_has_only(request, &["logicalArtifactId", "reason"]) + || !matches!( + request["logicalArtifactId"].as_str(), + Some("server-provider" | "server-admin-service" | "server-admin-service-iis") + ) + || request["reason"].as_str().is_none_or(|reason| { + reason.is_empty() + || [ + "all logs", + "entire", + "arbitrary", + "database", + "registry", + "drive", + ] + .iter() + .any(|term| reason.to_ascii_lowercase().contains(term)) + }) + { + failures.push("artifact request is not exact and bounded".to_owned()); + } + if let Some(source) = request["logicalArtifactId"].as_str() { + requested_sources.push(source); + } + } + let mut sorted_requested_sources = requested_sources.clone(); + sorted_requested_sources.sort_unstable(); + sorted_requested_sources.dedup(); + if requests.is_none() + || requested_sources != sorted_requested_sources + || requested_sources != expected_requested_sources(scenario) + { + failures.push("artifact requests are not sorted and unique".to_owned()); + } + + failures +} + +#[test] +fn provider_and_admin_service_scenario_matrix_is_exact() { + let actual = actual_scenarios().expect("provider/Admin Service fixture root exists"); + let expected = SCENARIOS + .into_iter() + .map(str::to_owned) + .collect::>(); + + assert_eq!(actual, expected); +} + +#[test] +fn provider_and_admin_service_sources_are_layered_and_role_exact() { + let provider = classify_artifact_name("Smsprov.log", SccmRole::Provider); + assert_eq!(provider.family, SccmArtifactFamily::Provider); + assert!(provider.uses_ccm_records); + assert!(provider.supported_for_diagnosis); + + let admin_service = classify_artifact_name("AdminService.log", SccmRole::Provider); + assert_eq!(admin_service.family, SccmArtifactFamily::AdminService); + assert!(admin_service.uses_ccm_records); + assert!(admin_service.supported_for_diagnosis); + + for role in [ + SccmRole::SiteServer, + SccmRole::ManagementPoint, + SccmRole::AdminService, + ] { + let wrong_provider = classify_artifact_name("Smsprov.log", role.clone()); + let wrong_admin = classify_artifact_name("AdminService.log", role); + assert!(matches!( + wrong_provider.family, + SccmArtifactFamily::Unknown(_) + )); + assert!(!wrong_provider.supported_for_diagnosis); + assert!(matches!(wrong_admin.family, SccmArtifactFamily::Unknown(_))); + assert!(!wrong_admin.supported_for_diagnosis); + } + + let iis = classify_artifact_name("u_ex_synthetic.log", SccmRole::Provider); + assert!(matches!(iis.family, SccmArtifactFamily::Unknown(_))); + assert!(!iis.uses_ccm_records); + assert!(!iis.supported_for_diagnosis); +} + +#[test] +fn provider_and_admin_service_contracts_are_closed_and_coverage_exact() { + let mut failures = Vec::new(); + for scenario in SCENARIOS { + let manifest = + read_json(scenario, "manifest.json").unwrap_or_else(|error| panic!("{error}")); + let expected = + read_json(scenario, "expected.json").unwrap_or_else(|error| panic!("{error}")); + for failure in schema_failures(scenario, &manifest, &expected) { + failures.push(format!("{scenario}: {failure}")); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn physical_evidence_is_synthetic_bounded_and_byte_exact() { + let mut failures = Vec::new(); + for scenario in SCENARIOS { + let manifest = + read_json(scenario, "manifest.json").unwrap_or_else(|error| panic!("{error}")); + for artifact in manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + { + let Some(relative_path) = artifact["relativePath"].as_str() else { + continue; + }; + let artifact_id = artifact["artifactId"].as_str().unwrap_or(""); + let path = corpus_root().join(scenario).join(relative_path); + let content = match fs::read_to_string(&path) { + Ok(content) => content, + Err(error) => { + failures.push(format!("{scenario}/{artifact_id}: {error}")); + continue; + } + }; + if artifact["bytesCopied"].as_u64() != Some(content.len() as u64) { + failures.push(format!( + "{scenario}/{artifact_id}: bytesCopied does not match physical bytes" + )); + } + if content.is_empty() + || !content.contains("SYNTHETIC") + || content.contains("C:\\") + || content.contains("\\\\") + || content.contains(".local") + || content.contains(".com") + { + failures.push(format!( + "{scenario}/{artifact_id}: evidence is empty or not safely synthetic" + )); + } + let byte_limit = artifact["collectionLimit"]["byteLimit"] + .as_u64() + .unwrap_or_default(); + if content.len() as u64 > byte_limit { + failures.push(format!( + "{scenario}/{artifact_id}: evidence exceeds collection cap" + )); + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn request_transactions_are_exact_cited_ordered_and_layer_local() { + let mut failures = Vec::new(); + for scenario in SCENARIOS { + let manifest = + read_json(scenario, "manifest.json").unwrap_or_else(|error| panic!("{error}")); + let expected = + read_json(scenario, "expected.json").unwrap_or_else(|error| panic!("{error}")); + let records = normalized_records(scenario, &manifest); + let artifact_layers = manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + .iter() + .filter_map(|artifact| { + Some(( + artifact["artifactId"].as_str()?.to_owned(), + artifact["layer"].as_str()?.to_owned(), + )) + }) + .collect::>(); + + let transactions = expected["transactions"] + .as_array() + .expect("transactions are an array"); + let outcomes = transactions + .iter() + .map(|transaction| { + ( + transaction["state"].as_str().unwrap_or_default(), + transaction["classification"].as_str().unwrap_or_default(), + transaction["confidence"].as_str().unwrap_or_default(), + ) + }) + .collect::>(); + if outcomes != expected_outcomes(scenario) { + failures.push(format!("{scenario}: outcome matrix changed")); + } + + for transaction in transactions { + let transaction_id = transaction["transactionId"].as_str().unwrap_or(""); + let layer = transaction["layer"].as_str().unwrap_or_default(); + let key = &transaction["key"]; + let mut prior_utc = i64::MIN; + let mut cited_terminal = false; + let mut cited_records = BTreeSet::new(); + for observation in transaction["observations"] + .as_array() + .expect("transaction observations are an array") + { + let observation_id = observation["observationId"].as_str().unwrap_or(""); + let phase = observation["phase"].as_str().unwrap_or_default(); + let disposition = observation["disposition"].as_str().unwrap_or_default(); + let terminal = observation["terminal"].as_bool().unwrap_or(false); + cited_terminal |= terminal; + for reference in observation["evidence"] + .as_array() + .expect("evidence is an array") + { + let artifact_id = reference["artifactId"].as_str().unwrap_or_default(); + let record_key = ( + artifact_id.to_owned(), + reference["startLine"].as_u64().unwrap_or_default() as u32, + reference["endLine"].as_u64().unwrap_or_default() as u32, + ); + let Some(record) = records.get(&record_key) else { + failures.push(format!( + "{scenario}/{observation_id}: citation is not one logical CCM record" + )); + continue; + }; + if !cited_records.insert(record_key) + || artifact_layers.get(artifact_id).map(String::as_str) != Some(layer) + { + failures.push(format!( + "{scenario}/{transaction_id}: evidence is reused or cross-layer" + )); + } + 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 [ + ("RequestId", key["requestId"].as_str()), + ("OperationHandle", key["operationHandle"].as_str()), + ("EndpointId", key["endpointId"].as_str()), + ("Layer", Some(layer)), + ("ProfileId", key["extractionProfileId"].as_str()), + ] { + 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 record.timestamp.ordering_state + != SccmTimeOrderingState::NormalizedUtc + || record.timestamp.utc_millis.is_none() + || record + .timestamp + .utc_millis + .is_some_and(|utc| utc < prior_utc) + { + failures.push(format!( + "{scenario}/{transaction_id}: time is unusable or reversed" + )); + } + if let Some(utc) = record.timestamp.utc_millis { + prior_utc = utc; + } + } + Some("unusableInvalidOffset") => { + if record.timestamp.ordering_state + != SccmTimeOrderingState::OffsetInvalid + { + failures.push(format!( + "{scenario}/{transaction_id}: invalid offset became 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" + )); + } + } + + if scenario == "rotation-boundary" && !records.is_empty() { + failures.push( + "rotation-boundary: partial rotation fragments formed logical evidence".to_owned(), + ); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn privacy_iis_and_missing_source_controls_stay_conservative() { + let privacy_manifest = read_json("privacy-redaction", "manifest.json").unwrap(); + let privacy_expected = read_json("privacy-redaction", "expected.json").unwrap(); + let privacy_public = serde_json::to_string(&privacy_expected) + .expect("privacy expected output serializes") + .to_ascii_lowercase(); + for raw in [ + "synthetic.user@example.invalid", + "synthetic-raw-bearer-do-not-export", + "select * from sms_r_system", + "/adminservice/v1.0/device", + ] { + assert!( + !privacy_public.contains(raw), + "public output leaked synthetic sensitive shape {raw}" + ); + } + let privacy_raw = privacy_manifest["artifacts"] + .as_array() + .unwrap() + .iter() + .filter_map(|artifact| artifact["relativePath"].as_str()) + .map(|path| fs::read_to_string(corpus_root().join("privacy-redaction").join(path)).unwrap()) + .collect::() + .to_ascii_lowercase(); + assert!(privacy_raw.contains("synthetic.user@example.invalid")); + assert!(privacy_raw.contains("synthetic-raw-bearer-do-not-export")); + assert!(privacy_raw.contains("select * from sms_r_system")); + assert!(privacy_raw.contains("/adminservice/v1.0/device")); + + let privacy_transactions = privacy_expected["transactions"].as_array().unwrap(); + assert_eq!(privacy_transactions.len(), 2); + assert_eq!( + privacy_transactions[0]["key"]["requestId"], + privacy_transactions[1]["key"]["requestId"] + ); + assert_ne!( + privacy_transactions[0]["transactionId"], + privacy_transactions[1]["transactionId"] + ); + assert_ne!( + privacy_transactions[0]["layer"], + privacy_transactions[1]["layer"] + ); + + let iis_expected = read_json("iis-supplemental", "expected.json").unwrap(); + let iis_transaction_evidence = serde_json::to_string(&iis_expected["transactions"]).unwrap(); + assert!(!iis_transaction_evidence.contains("iis-supplemental-current")); + assert_eq!( + iis_expected["sourceLocalObservations"][0]["kind"], + "supplementalOnly" + ); + assert_eq!( + iis_expected["sourceLocalObservations"][0]["correlationEligible"], + false + ); + + let provider_timeout = read_json("provider-timeout", "expected.json").unwrap(); + assert_eq!( + provider_timeout["artifactRequests"][0]["logicalArtifactId"], + "server-provider" + ); + let incomplete = read_json("incomplete", "expected.json").unwrap(); + assert_eq!( + incomplete["artifactRequests"][0]["logicalArtifactId"], + "server-admin-service" + ); +} + +#[test] +fn schema_and_identity_mutations_fail_closed() { + let manifest = read_json("provider-success", "manifest.json").unwrap(); + let expected = read_json("provider-success", "expected.json").unwrap(); + let iis_manifest = read_json("iis-supplemental", "manifest.json").unwrap(); + let iis_expected = read_json("iis-supplemental", "expected.json").unwrap(); + let timeout_manifest = read_json("provider-timeout", "manifest.json").unwrap(); + let timeout_expected = read_json("provider-timeout", "expected.json").unwrap(); + let mut accepted = Vec::new(); + + let mut extra_field = manifest.clone(); + extra_field["unexpected"] = Value::Bool(true); + if schema_failures("provider-success", &extra_field, &expected).is_empty() { + accepted.push("unknown manifest field"); + } + + let mut wrong_role = manifest.clone(); + wrong_role["artifacts"][0]["producerRole"] = Value::String("adminService".to_owned()); + if schema_failures("provider-success", &wrong_role, &expected).is_empty() { + accepted.push("wrong producer role"); + } + + let mut unknown_version = manifest.clone(); + unknown_version["artifacts"][0]["sourceVersion"] = Value::String("9.99.UNKNOWN".to_owned()); + if schema_failures("provider-success", &unknown_version, &expected).is_empty() { + accepted.push("unknown version retained selected profile"); + } + + let mut control_version = manifest.clone(); + control_version["artifacts"][0]["sourceVersion"] = + Value::String("5.00.TEST.0001\n9.99.UNKNOWN".to_owned()); + if schema_failures("provider-success", &control_version, &expected).is_empty() { + accepted.push("control-character source version retained selected profile"); + } + + let mut blank_artifact = manifest.clone(); + blank_artifact["artifacts"][0]["artifactId"] = Value::String(String::new()); + let mut blank_artifact_expected = expected.clone(); + blank_artifact_expected["coverage"][0]["artifactId"] = Value::String(String::new()); + for observation in blank_artifact_expected["transactions"][0]["observations"] + .as_array_mut() + .unwrap() + { + observation["evidence"][0]["artifactId"] = Value::String(String::new()); + } + if schema_failures( + "provider-success", + &blank_artifact, + &blank_artifact_expected, + ) + .is_empty() + { + accepted.push("blank artifact identity with internally rewritten references"); + } + + let mut unsafe_path = manifest.clone(); + unsafe_path["artifacts"][0]["relativePath"] = + Value::String("evidence/../outside/Smsprov.log".to_owned()); + if schema_failures("provider-success", &unsafe_path, &expected).is_empty() { + accepted.push("unsafe relative path"); + } + + let mut endpoint_only = expected.clone(); + endpoint_only["transactions"][0]["key"]["requestId"] = Value::String(String::new()); + if schema_failures("provider-success", &manifest, &endpoint_only).is_empty() { + accepted.push("endpoint-only key"); + } + + let mut time_only = expected.clone(); + time_only["transactions"][0]["key"]["requestId"] = Value::String("same-minute-only".to_owned()); + time_only["transactions"][0]["key"]["operationHandle"] = + Value::String("safe-operation-time-only".to_owned()); + time_only["transactions"][0]["transactionId"] = Value::String( + "provider:same-minute-only:safe-operation-time-only:provider-local".to_owned(), + ); + if schema_failures("provider-success", &manifest, &time_only).is_empty() { + accepted.push("time-like arbitrary key replaced profile fixture identity"); + } + + let mut non_array_evidence = expected.clone(); + non_array_evidence["transactions"][0]["observations"][0]["evidence"] = Value::Bool(false); + if schema_failures("provider-success", &manifest, &non_array_evidence).is_empty() { + accepted.push("non-array transaction evidence"); + } + + let mut duplicate_observation = expected.clone(); + let first = duplicate_observation["transactions"][0]["observations"][0].clone(); + duplicate_observation["transactions"][0]["observations"] + .as_array_mut() + .unwrap() + .push(first); + if schema_failures("provider-success", &manifest, &duplicate_observation).is_empty() { + accepted.push("duplicate transaction observation"); + } + + let mut arbitrary_observation = expected.clone(); + arbitrary_observation["transactions"][0]["observations"][0]["observationId"] = + Value::String("arbitrary-observation".to_owned()); + if schema_failures("provider-success", &manifest, &arbitrary_observation).is_empty() { + accepted.push("arbitrary transaction observation identity"); + } + + let mut incomplete_high = manifest.clone(); + incomplete_high["artifacts"][0]["rotation"]["fragmentComplete"] = Value::Bool(false); + if schema_failures("provider-success", &incomplete_high, &expected).is_empty() { + accepted.push("incomplete physical fragment retained high confidence"); + } + + let mut altered_outcome = expected.clone(); + altered_outcome["transactions"][0]["state"] = Value::String("unknown".to_owned()); + if schema_failures("provider-success", &manifest, &altered_outcome).is_empty() { + accepted.push("arbitrary transaction outcome"); + } + + let mut iis_as_primary = iis_expected.clone(); + iis_as_primary["transactions"][0]["observations"][0]["evidence"][0]["artifactId"] = + Value::String("iis-supplemental-current".to_owned()); + if schema_failures("iis-supplemental", &iis_manifest, &iis_as_primary).is_empty() { + accepted.push("supplemental IIS evidence became an Admin Service transaction"); + } + + let mut arbitrary_source_local = iis_expected.clone(); + arbitrary_source_local["sourceLocalObservations"][0]["observationId"] = + Value::String("arbitrary-source-local".to_owned()); + if schema_failures("iis-supplemental", &iis_manifest, &arbitrary_source_local).is_empty() { + accepted.push("arbitrary source-local observation identity"); + } + + let mut missing_request = timeout_expected.clone(); + missing_request["artifactRequests"] = Value::Array(Vec::new()); + if schema_failures("provider-timeout", &timeout_manifest, &missing_request).is_empty() { + accepted.push("required bounded follow-up request omitted"); + } + + let mut unsafe_summary = expected.clone(); + unsafe_summary["transactions"][0]["publicSummary"] = + Value::String("SELECT * FROM SMS_R_System for user@example.invalid".to_owned()); + if schema_failures("provider-success", &manifest, &unsafe_summary).is_empty() { + accepted.push("sensitive public summary"); + } + + let mut broad_request = expected.clone(); + broad_request["artifactRequests"] = serde_json::json!([{ + "logicalArtifactId": "server-provider", + "reason": "Collect all logs from the entire drive." + }]); + if schema_failures("provider-success", &manifest, &broad_request).is_empty() { + accepted.push("broad artifact request"); + } + + let mut cross_side_claim = expected.clone(); + cross_side_claim["crossSideCausalClaims"] = + serde_json::json!(["same-time client failure was caused"]); + if schema_failures("provider-success", &manifest, &cross_side_claim).is_empty() { + accepted.push("cross-side causal claim"); + } + + assert!(accepted.is_empty(), "accepted mutations: {accepted:#?}"); +} 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..a2ddb6720 --- /dev/null +++ b/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md @@ -0,0 +1,107 @@ +# Issue #332 Provider/Admin Service preparation + +## Status + +This slice prepares the source and reducer contracts only. Production +extraction/reduction waits for reviewed, stable #318 and #335 interfaces. 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. + +#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 exact 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. + +## 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. From 0c15edb1c8d4d8569c9db40abf8bb451f7ab9b06 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 00:42:44 -0400 Subject: [PATCH 076/422] test(sccm): close identity and rotation contracts --- .../inventory-compliance-metering/README.md | 7 + .../root-a/current/InventoryAgent.log | 1 + .../root-a/current/InventoryAgentProvider.log | 1 - .../inventory/rotation-boundary/expected.json | 8 +- .../inventory/rotation-boundary/manifest.json | 26 +- ...ry_compliance_metering_fixture_contract.rs | 434 +++++++++++++++++- ...nt-inventory-compliance-metering-corpus.md | 21 +- 7 files changed, 461 insertions(+), 37 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/evidence/client-inventory/root-a/current/InventoryAgent.log delete mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/evidence/client-inventory/root-a/current/InventoryAgentProvider.log 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 index acaccbf8b..0550b5ce8 100644 --- 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 @@ -26,6 +26,13 @@ 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 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/current/InventoryAgentProvider.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/evidence/client-inventory/root-a/current/InventoryAgentProvider.log deleted file mode 100644 index 5157b3527..000000000 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/rotation-boundary/evidence/client-inventory/root-a/current/InventoryAgentProvider.log +++ /dev/null @@ -1 +0,0 @@ - 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 index 176473e5e..efb51d804 100644 --- 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 @@ -13,8 +13,8 @@ "observationId": "inventory-rotation-split", "kind": "rotationSplit", "artifactIds": [ - "inventory-rotation-boundary-agent-lo", - "inventory-rotation-boundary-report-current" + "inventory-rotation-boundary-agent-current", + "inventory-rotation-boundary-agent-lo" ], "confidenceCeiling": "low", "correlationEligible": false, @@ -23,12 +23,12 @@ ], "coverage": [ { - "artifactId": "inventory-rotation-boundary-agent-lo", + "artifactId": "inventory-rotation-boundary-agent-current", "logicalArtifactId": "client-inventory", "state": "partial" }, { - "artifactId": "inventory-rotation-boundary-report-current", + "artifactId": "inventory-rotation-boundary-agent-lo", "logicalArtifactId": "client-inventory", "state": "partial" } 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 index 44dd2ba79..f5e3083a8 100644 --- 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 @@ -15,7 +15,7 @@ }, "artifacts": [ { - "artifactId": "inventory-rotation-boundary-agent-lo", + "artifactId": "inventory-rotation-boundary-agent-current", "designOnlyCatalog": { "entryId": "client-inventory", "groupMemberships": [ @@ -25,17 +25,17 @@ "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", + "originalBasename": "InventoryAgent.log", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgent.log", + "pathFingerprint": "synthetic-inventory-rotation-boundary-agent-current-root-a", "rotation": { - "kind": "lo", + "kind": "current", "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", + "relativePath": "evidence/client-inventory/root-a/current/InventoryAgent.log", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, @@ -43,7 +43,7 @@ } }, { - "artifactId": "inventory-rotation-boundary-report-current", + "artifactId": "inventory-rotation-boundary-agent-lo", "designOnlyCatalog": { "entryId": "client-inventory", "groupMemberships": [ @@ -53,17 +53,17 @@ "role": "client", "kind": "ccmLog", "captureState": "captured", - "originalBasename": "InventoryAgentProvider.log", - "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgentProvider.log", - "pathFingerprint": "synthetic-inventory-rotation-boundary-report-current-root-a", + "originalBasename": "InventoryAgent.log.lo", + "sanitizedSourcePath": "SYNTHETIC://root-a/CCM/Logs/InventoryAgent.log.lo", + "pathFingerprint": "synthetic-inventory-rotation-boundary-agent-lo-root-a", "rotation": { - "kind": "current", + "kind": "lo", "fragmentComplete": false }, "sourceVersion": "5.00.TEST.325", "capturedUtc": "2026-07-30T04:00:00Z", - "bytesCopied": 314, - "relativePath": "evidence/client-inventory/root-a/current/InventoryAgentProvider.log", + "bytesCopied": 308, + "relativePath": "evidence/client-inventory/root-a/lo/InventoryAgent.log.lo", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, 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 index 5c032c80d..4945bf7fa 100644 --- 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 @@ -36,7 +36,7 @@ const METERING_SCENARIOS: [&str; 6] = [ "terminal-failures", ]; -const DOCUMENTED_CORPUS_DIGEST: &str = "409f976350ffbc05"; +const DOCUMENTED_CORPUS_DIGEST: &str = "26c8cf8aee0741a2"; #[derive(Debug, PartialEq, Eq)] struct CorpusInventory { @@ -448,6 +448,37 @@ fn required_string<'a>(value: &'a Value, field: &str, context: &str) -> Result<& .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], @@ -908,6 +939,72 @@ fn strict_ccm_structured_fields( 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 @@ -1148,9 +1245,11 @@ fn validate_contract( 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() @@ -1273,8 +1372,11 @@ fn validate_contract( )) } }; - if source_version.is_some_and(|version| !version.starts_with("5.00.TEST.")) { - unknown_version_artifacts.insert(artifact_id.to_owned()); + 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 { @@ -1522,6 +1624,7 @@ fn validate_contract( .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(); @@ -1539,6 +1642,7 @@ fn validate_contract( "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}")); } @@ -1574,6 +1678,7 @@ fn validate_contract( 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() @@ -1594,25 +1699,56 @@ fn validate_contract( .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 kind == "unknownProfile" - && !unknown_profile_observations.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 unknown-profile observation for {artifact_id}" + "{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" => { - observed_states.len() < 2 - || observed_states.iter().any(|state| state != "partial") - || observed_rotations != ["current", "lo"].into_iter().collect::>() - } + "rotationSplit" => false, "malformedRecord" => observed_states.iter().any(|state| state != "parseFailed"), "unknownProfile" => artifact_ids.iter().any(|artifact_id| { !artifact_id @@ -1677,6 +1813,7 @@ fn validate_contract( "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}")); } @@ -2125,7 +2262,7 @@ fn corpus_inventory_is_deterministic_and_documented() { scenarios: 20, artifacts: 54, evidence_files: 42, - evidence_bytes: 16_820, + evidence_bytes: 16_814, capture_states: BTreeMap::from([ ("absent".to_owned(), 3), ("accessDenied".to_owned(), 3), @@ -3564,6 +3701,74 @@ fn review_blocker_bundle_identity_and_order_descriptors_are_closed() { 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", ""), + ("control-bearing artifactId", "metering-success\nforeign"), + ( + "foreign-family artifactId", + "inventory-success-report-current", + ), + ] { + 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", ""), + ("control-bearing transactionId", "metering-success\nforeign"), + ("foreign-family transactionId", "inventory-success"), + ] { + 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", ""), + ( + "control-bearing observationId", + "metering-coverage-only\nforeign", + ), + ("foreign-family observationId", "inventory-coverage-only"), + ] { + 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( @@ -3810,6 +4015,33 @@ fn review_blocker_nonphysical_optional_field_json_types_are_closed() { 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"); @@ -3864,7 +4096,7 @@ fn review_blocker_unknown_versions_are_profile_gaps_for_every_coverage_state() { .expect("sourceLocalObservations are an array") .push(json!({ "observationId": format!( - "inventory-coverage-unknown-profile-{capture_state}" + "inventory-coverage-unknown-profile-{artifact_id}" ), "kind": "unknownProfile", "artifactIds": [artifact_id], @@ -3909,6 +4141,180 @@ fn review_blocker_unknown_versions_are_profile_gaps_for_every_coverage_state() { 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"); 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 index f9ee804c2..d5f5ff0a1 100644 --- a/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md +++ b/docs/sccm/preparation/issue-325-client-inventory-compliance-metering-corpus.md @@ -39,15 +39,16 @@ The profile identifiers in this corpus are deliberately test-only: - `sccm-client-compliance-5.00.test-v1` - `sccm-client-metering-5.00.test-v1` -An unknown source version has no fallback profile. It remains a source-local, -low-confidence observation and a coverage/profile gap. +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,820 bytes). The deterministic fixture digest is `409f976350ffbc05`. +(16,814 bytes). The deterministic fixture digest is `26c8cf8aee0741a2`. | Family | Scenarios | Contract coverage | | --- | --- | --- | @@ -68,7 +69,8 @@ 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 physical artifact identity and logical workflow membership; +- 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`, @@ -112,7 +114,10 @@ The expected contract keeps output deterministic and preparation-only: 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, and are not correlation eligible; + 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. @@ -149,6 +154,12 @@ of: - 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 From c303e44ee2cde7eec21847fde3a2b868b29f56e2 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 00:46:05 -0400 Subject: [PATCH 077/422] test(sccm): cover identity length bound --- ...ry_compliance_metering_fixture_contract.rs | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) 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 index 4945bf7fa..c5e1f5182 100644 --- 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 @@ -3706,19 +3706,26 @@ fn review_blocker_public_identities_are_nonempty_control_free_and_scoped() { let mut failures = Vec::new(); for (label, replacement) in [ - ("blank artifactId", ""), - ("control-bearing artifactId", "metering-success\nforeign"), + ("blank artifactId", String::new()), + ( + "control-bearing artifactId", + "metering-success\nforeign".to_owned(), + ), ( "foreign-family artifactId", - "inventory-success-report-current", + "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]["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); + expected["coverage"][0]["artifactId"] = json!(&replacement); + expected["transactions"][0]["evidence"][0]["artifactId"] = json!(&replacement); collect_contract_rejection( &mut failures, label, @@ -3728,12 +3735,22 @@ fn review_blocker_public_identities_are_nonempty_control_free_and_scoped() { } for (label, replacement) in [ - ("blank transactionId", ""), - ("control-bearing transactionId", "metering-success\nforeign"), - ("foreign-family transactionId", "inventory-success"), + ("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); + expected["transactions"][0]["transactionId"] = json!(&replacement); collect_contract_rejection( &mut failures, label, @@ -3743,15 +3760,22 @@ fn review_blocker_public_identities_are_nonempty_control_free_and_scoped() { } for (label, replacement) in [ - ("blank observationId", ""), + ("blank observationId", String::new()), ( "control-bearing observationId", - "metering-coverage-only\nforeign", + "metering-coverage-only\nforeign".to_owned(), + ), + ( + "foreign-family observationId", + "inventory-coverage-only".to_owned(), + ), + ( + "overlong observationId", + format!("metering-{}", "a".repeat(120)), ), - ("foreign-family observationId", "inventory-coverage-only"), ] { let (scenario_root, manifest, mut expected) = load_contract("metering", "coverage-states"); - expected["sourceLocalObservations"][0]["observationId"] = json!(replacement); + expected["sourceLocalObservations"][0]["observationId"] = json!(&replacement); collect_contract_rejection( &mut failures, label, From 5efc7fb8c96c63d1becfb07d5ada9edb74a7e186 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 00:49:05 -0400 Subject: [PATCH 078/422] feat(sccm): catalog advanced server role sources --- .../missing-required-field/expected.json | 8 + .../missing-required-field/source-card.json | 72 ++ .../redaction-required/expected.json | 10 + .../redaction-required/source-card.json | 79 ++ .../unvalidated-source/expected.json | 9 + .../unvalidated-source/source-card.json | 73 ++ .../catalog-fixtures/valid/expected.json | 6 + .../catalog-fixtures/valid/source-card.json | 73 ++ .../certificate-enrollment-pki.json | 80 ++ .../source-cards/client-notification-bgb.json | 81 ++ .../cloud-service-connection.json | 83 ++ .../advanced_roles/source-cards/osd-pxe.json | 82 ++ .../source-cards/reporting.json | 82 ++ .../source-cards/sql-database-export.json | 79 ++ .../sccm_server_advanced_roles_catalog.rs | 740 ++++++++++++++++++ docs/sccm/source-catalog/advanced-roles.md | 59 ++ 16 files changed, 1616 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/missing-required-field/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/missing-required-field/source-card.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/redaction-required/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/redaction-required/source-card.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/unvalidated-source/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/unvalidated-source/source-card.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/valid/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/catalog-fixtures/valid/source-card.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/certificate-enrollment-pki.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/client-notification-bgb.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/cloud-service-connection.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/osd-pxe.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/reporting.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/advanced_roles/source-cards/sql-database-export.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs create mode 100644 docs/sccm/source-catalog/advanced-roles.md 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/sccm_server_advanced_roles_catalog.rs b/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs new file mode 100644 index 000000000..27ab3d0c3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs @@ -0,0 +1,740 @@ +use std::collections::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 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()); + } + _ => {} + } + + 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()), + } + + 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 = + issues.is_empty() && matches!(card.promotion.state, PromotionState::RuleValidated); + Validation { + valid: issues.is_empty(), + admitted_to_semantic_catalog, + issues, + } +} + +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 mut card_ids = Vec::new(); + for filename in SOURCE_CARDS { + let path = root.join(filename); + let card = load_card(&path).unwrap_or_else(|error| panic!("{filename}: {error}")); + let validation = validate_card(&card); + 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.supersession.superseded_by = Some("advanced-role-successor".to_owned()); + let valid = validate_card(&card); + assert!(!valid + .issues + .contains(&"deprecatedCardRequiresSuccessor".to_owned())); + assert!( + !valid.admitted_to_semantic_catalog, + "deprecation metadata cannot promote a candidate source" + ); +} + +#[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); +} diff --git a/docs/sccm/source-catalog/advanced-roles.md b/docs/sccm/source-catalog/advanced-roles.md new file mode 100644 index 000000000..b68215fa6 --- /dev/null +++ b/docs/sccm/source-catalog/advanced-roles.md @@ -0,0 +1,59 @@ +# 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 an explicit successor, and supersession metadata cannot promote a card. + +The synthetic catalog-fixture matrix 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; +- unknown parser and promotion values are retained for review and rejected; +- deprecation without an explicit successor is rejected. + +## 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. From 0e9feef428d84074d432d86edf963335c0a677ca Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 00:51:31 -0400 Subject: [PATCH 079/422] docs(sccm): fix provider corpus issue reference --- .../sccm/preparation/issue-332-provider-admin-service-corpus.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md b/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md index a2ddb6720..0e7dff83e 100644 --- a/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md +++ b/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md @@ -15,7 +15,7 @@ The existing pure Rust catalog already distinguishes: - `AdminService.log` as Admin-Service-family CCM from the `provider` producer role. -#332 keeps producer role and workflow layer separate. The proposed +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 From ca7ee943794f0654c2502606088743ac0b9db7c0 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 00:52:00 -0400 Subject: [PATCH 080/422] fix(sccm): close finding contract gaps --- .../cmtraceopen-parser/src/sccm/evidence.rs | 44 +++- .../cmtraceopen-parser/src/sccm/findings.rs | 74 +++++++ .../tests/sccm_spine_contract.rs | 203 +++++++++++++++++- 3 files changed, 304 insertions(+), 17 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/evidence.rs b/crates/cmtraceopen-parser/src/sccm/evidence.rs index c11a9d472..75134fb3d 100644 --- a/crates/cmtraceopen-parser/src/sccm/evidence.rs +++ b/crates/cmtraceopen-parser/src/sccm/evidence.rs @@ -53,6 +53,16 @@ fn windows_identity_re() -> &'static Regex { }) } +fn windows_user_path_identity_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new( + r"(?i)(?:^|[\\/])users[\\/](?P(?:NT 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(|| { @@ -125,19 +135,37 @@ fn sensitive_value_end(value: &str, value_start: usize) -> usize { 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(); - for matched in windows_identity_re().find_iter(value) { - let preceding = value[..matched.start()].chars().next_back(); - let following = value[matched.end()..].chars().next(); - let path_adjacent = - matches!(preceding, Some('\\' | '/')) || matches!(following, Some('\\' | '/')); - if path_adjacent { + for (start, end) in identity_ranges { + if start < copied_through { continue; } - projected.push_str(&value[copied_through..matched.start()]); + projected.push_str(&value[copied_through..start]); projected.push_str(PUBLIC_MESSAGE_REDACTION); - copied_through = matched.end(); + copied_through = end; } projected.push_str(&value[copied_through..]); diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 4ecf4141a..107628218 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::models::log_entry::Severity; use super::catalog::declared_source_catalog; +use super::keys::normalize_key; use super::models::{ SccmCorrelationKey, SccmCorrelationKeyKind, SccmCoverageState, SccmEvidenceRef, SccmFindingClass, SccmKeyConfidence, SccmRole, @@ -459,6 +460,7 @@ pub enum SccmFindingValidationError { TerminalEvidenceNotCited, CorrelationKeyMissingEvidence, CorrelationKeyEvidenceNotCited, + InvalidCorrelationKey, LikelyContributorConfidenceTooHigh, MissingCoverageGap, InvalidCoverageGap, @@ -1187,6 +1189,7 @@ fn confirmation_clause_is_non_authorizing( || 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; @@ -1259,6 +1262,46 @@ fn confirmation_clause_is_non_authorizing( 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 subject = &tokens[1..auxiliary_index]; + let has_broad_target = subject.iter().any(|token| is_broad_quantifier(token.text)) + && subject.iter().any(|token| { + is_collection_target(token.text) + && !token_is_covered_by_identity(token, identity_ranges) + }); + if !has_broad_target { + return false; + } + + matches!(auxiliary.text, "must" | "need" | "needs" | "should") + || tokens[auxiliary_index + 1..].iter().any(|token| { + matches!( + token.text, + "archived" + | "captured" + | "collected" + | "copied" + | "exported" + | "gathered" + | "included" + | "provided" + | "required" + ) + }) + }) +} + fn confirmation_tokens_match_defined_form( tokens: &[RequestReasonToken<'_>], identity_ranges: &[(usize, usize)], @@ -1979,6 +2022,37 @@ fn validate_correlation_key_evidence( 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 + && !key.normalized.is_empty() + && key.normalized.trim() == key.normalized + && 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); }; diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 1dabffa71..62eeee5d5 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -382,6 +382,13 @@ const REVIEW_UNRECOGNIZED_CONFIRMATION_REQUEST_REASONS: [&str; 10] = [ "Confirm PolicyAgent.log for fetching credentials.", ]; +const REVIEW_PASSIVE_UNBOUNDED_CONFIRMATION_REASONS: [&str; 4] = [ + "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.", +]; + 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."), @@ -598,7 +605,7 @@ fn finding_rejects_unknown_phase_values_that_shadow_declared_names() { } #[test] -fn finding_forged_unregistered_profile_never_authorizes_high_corroboration() { +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") @@ -630,12 +637,93 @@ fn finding_forged_unregistered_profile_never_authorizes_high_corroboration() { assert_eq!( result.unwrap_err(), - SccmFindingValidationError::MissingTerminalEvidence + SccmFindingValidationError::InvalidCorrelationKey ); } #[test] -fn finding_duplicate_one_ref_never_counts_as_two_ref_corroboration() { +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 mut json = serde_json::to_value(&canonical).unwrap(); + json["correlationKeys"] = serde_json::to_value([key]).unwrap(); + 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, @@ -657,7 +745,7 @@ fn finding_duplicate_one_ref_never_counts_as_two_ref_corroboration() { assert_eq!( result.unwrap_err(), - SccmFindingValidationError::MissingTerminalEvidence + SccmFindingValidationError::InvalidCorrelationKey ); } @@ -682,7 +770,7 @@ fn finding_same_minute_keyless_evidence_never_counts_as_high_confidence() { } #[test] -fn finding_mismatched_keys_or_profiles_never_corroborate_high_confidence() { +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 = [ @@ -739,7 +827,7 @@ fn finding_mismatched_keys_or_profiles_never_corroborate_high_confidence() { assert_eq!( result.unwrap_err(), - SccmFindingValidationError::MissingTerminalEvidence, + SccmFindingValidationError::InvalidCorrelationKey, "{finding_id}" ); } @@ -2207,6 +2295,54 @@ fn finding_review_unrecognized_confirmation_requests_fail_at_every_public_bounda ); } +#[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::to_value(finding_request(logical_id, SccmRole::Client, reason)).unwrap(); + 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_exact_mp_identity_passes_every_public_boundary() { let canonical = finding_with_gap_and_request("review-exact-mp-identity-parity"); @@ -2384,16 +2520,16 @@ fn finding_rejects_a_correlation_key_without_an_evidence_ref() { } #[test] -fn finding_low_or_unprofiled_keys_never_corroborate_high_confidence() { +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-keys", + "low-profiled-keys", Some("sccm-keys-experimental-v1"), SccmKeyConfidence::Low, ), - ("unprofiled-keys", None, SccmKeyConfidence::Exact), + ("low-unprofiled-keys", None, SccmKeyConfidence::Low), ]; for (finding_id, profile, confidence) in cases { @@ -4521,6 +4657,55 @@ fn evidence_public_message_projection_redacts_colon_delimited_windows_identities } } +#[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 serde_roles_are_string_backed_and_future_tolerant() { assert_eq!( From a515f33858be599759dd84e02353bc1f92f4a16f Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 00:58:11 -0400 Subject: [PATCH 081/422] fix(sccm): reject trailing broad confirmation targets --- crates/cmtraceopen-parser/src/sccm/findings.rs | 6 +++--- crates/cmtraceopen-parser/tests/sccm_spine_contract.rs | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 107628218..2539f9ceb 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -1274,9 +1274,9 @@ fn has_passive_unbounded_confirmation_request( return false; } - let subject = &tokens[1..auxiliary_index]; - let has_broad_target = subject.iter().any(|token| is_broad_quantifier(token.text)) - && subject.iter().any(|token| { + 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) }); diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 62eeee5d5..6486c5ccc 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -382,11 +382,15 @@ const REVIEW_UNRECOGNIZED_CONFIRMATION_REQUEST_REASONS: [&str; 10] = [ "Confirm PolicyAgent.log for fetching credentials.", ]; -const REVIEW_PASSIVE_UNBOUNDED_CONFIRMATION_REASONS: [&str; 4] = [ +const REVIEW_PASSIVE_UNBOUNDED_CONFIRMATION_REASONS: [&str; 8] = [ "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.", ]; const REVIEW_EXACT_MP_ARTIFACT_REQUESTS: [(&str, &str); 5] = [ From 6c5565e3900114190082d0a9d34eeb3d547abb83 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:01:25 -0400 Subject: [PATCH 082/422] test(sccm): close SUP fixture provenance gaps --- .../server/software_update_point/README.md | 5 + .../cross-family-lo-wcm.log} | 0 ...incomplete-required-numbered-wsyncmgr.log} | 0 .../manifest.json | 25 + .../parse-failed-valid-numbered-wsusctrl.log} | 0 ..._software_update_point_fixture_contract.rs | 472 ++++++++++++++++-- .../issue-330-software-update-point-corpus.md | 14 + 7 files changed, 482 insertions(+), 34 deletions(-) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/{software_update_point/rotation-boundary/evidence/server-sup-sync/site/lo_/WCM.log => software_update_point_mutation_assets/cross-family-lo-wcm.log} (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/{software_update_point/sync-success/evidence/server-sup-sync/site/numbered/wsyncmgr.log => software_update_point_mutation_assets/incomplete-required-numbered-wsyncmgr.log} (100%) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/manifest.json rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/{software_update_point/rotation-boundary/evidence/server-sup-sync/sup/numbered/WSUSCtrl.log => software_update_point_mutation_assets/parse-failed-valid-numbered-wsusctrl.log} (100%) 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 index 0444745ea..ec4d27b55 100644 --- 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 @@ -10,6 +10,11 @@ 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 diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/site/lo_/WCM.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/cross-family-lo-wcm.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/site/lo_/WCM.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/cross-family-lo-wcm.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/site/numbered/wsyncmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/incomplete-required-numbered-wsyncmgr.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/sync-success/evidence/server-sup-sync/site/numbered/wsyncmgr.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/incomplete-required-numbered-wsyncmgr.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/manifest.json new file mode 100644 index 000000000..ae2431736 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/manifest.json @@ -0,0 +1,25 @@ +{ + "contractVersion": 1, + "syntheticFixture": true, + "testOnly": true, + "assets": [ + { + "assetId": "cross-family-lo-wcm", + "relativePath": "cross-family-lo-wcm.log", + "bytesCopied": 192, + "testPurpose": "rejectCrossFamilyRotationGrouping" + }, + { + "assetId": "incomplete-required-numbered-wsyncmgr", + "relativePath": "incomplete-required-numbered-wsyncmgr.log", + "bytesCopied": 182, + "testPurpose": "rejectIncompleteRequiredRotationSuccess" + }, + { + "assetId": "parse-failed-valid-numbered-wsusctrl", + "relativePath": "parse-failed-valid-numbered-wsusctrl.log", + "bytesCopied": 326, + "testPurpose": "rejectParseFailedUsableCcm" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/sup/numbered/WSUSCtrl.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/parse-failed-valid-numbered-wsusctrl.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point/rotation-boundary/evidence/server-sup-sync/sup/numbered/WSUSCtrl.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/software_update_point_mutation_assets/parse-failed-valid-numbered-wsusctrl.log 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 index cf2c1a323..4d3ca41a1 100644 --- 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 @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; -use chrono::DateTime; +use chrono::{DateTime, NaiveDateTime}; use cmtraceopen_parser::sccm::{ normalize_ccm_artifact, SccmArtifact, SccmCoverageState, SccmEvidence, SccmRole, SccmRotation, SccmTimeOrderingState, @@ -158,6 +158,39 @@ fn corpus_root() -> std::path::PathBuf { .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) @@ -278,11 +311,20 @@ fn rotation_from_manifest(rotation: &Value) -> Result { "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 u32 value".to_owned()), - "timestamped" => required_string(rotation, "value", "rotation") - .map(str::to_owned) - .map(SccmRotation::Timestamped), + .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}")), } } @@ -413,6 +455,48 @@ fn sanitized_source_path_is_safe(value: &str) -> bool { }) } +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() @@ -447,6 +531,7 @@ fn validate_manifest( scenario: &str, scenario_root: &std::path::Path, manifest: &Value, + fixture_overrides: &BTreeMap>, ) -> Result> { let mut failures = Vec::new(); reject_unknown_fields( @@ -666,20 +751,25 @@ fn validate_manifest( "timestamped" => artifact["rotation"]["value"].as_str().is_some(), _ => false, }; - if rotation_lineage.is_empty() || !rotation_value_shape_valid { + 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" )); } - let rotation_value = artifact["rotation"]["value"] - .as_str() - .map(str::to_owned) - .or_else(|| { - artifact["rotation"]["value"] - .as_u64() - .map(|value| value.to_string()) - }) - .unwrap_or_default(); + 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() @@ -689,9 +779,6 @@ fn validate_manifest( .as_str() .unwrap_or_default() .to_ascii_lowercase(), - basename.to_ascii_lowercase(), - rotation_kind.clone(), - rotation_value, ); if !physical_identities.insert(identity) { failures.push(format!( @@ -730,7 +817,7 @@ fn validate_manifest( continue; } }; - let rotation_model = match rotation_from_manifest(&artifact["rotation"]) { + let rotation_model = match rotation_model { Ok(value) => value, Err(error) => { failures.push(format!("{artifact_id}: {error}")); @@ -756,21 +843,31 @@ fn validate_manifest( "{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 std::fs::read(&fixture_path) { - Ok(value) => value, - Err(error) => { - failures.push(format!( - "{} is readable for {artifact_id}: {error}", - fixture_path.display() - )); - continue; - } + 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!( @@ -1845,9 +1942,18 @@ 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)?; + let parsed = validate_manifest(scenario, &scenario_root, manifest, fixture_overrides)?; validate_expected(scenario, manifest, expected, &parsed) } @@ -1855,6 +1961,20 @@ fn mutation_was_accepted(scenario: &str, manifest: &Value, expected: &Value) -> 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() +} + #[test] fn software_update_point_scenario_matrix_is_complete_and_loadable() { let root = corpus_root(); @@ -1883,6 +2003,152 @@ fn software_update_point_scenario_matrix_is_complete_and_loadable() { } } +#[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_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())); + 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"; @@ -1900,6 +2166,138 @@ fn structured_fields_are_unique_closed_and_not_nested_ccm() { } } +#[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"); @@ -2468,7 +2866,7 @@ fn partial_capture_malformed_bytes_and_rotation_family_fail_closed() { "limitApplied": false }, "bytesCopied": 182, - "relativePath": "evidence/server-sup-sync/site/numbered/wsyncmgr.log" + "relativePath": "evidence/server-sup-sync/site/numbered-1/wsyncmgr.log" })); let mut incomplete_required_expected = success_expected.clone(); incomplete_required_expected["coverage"] @@ -2478,10 +2876,12 @@ fn partial_capture_malformed_bytes_and_rotation_family_fail_closed() { "artifactId": "sync-success-04-wsync-partial", "state": "captured" })); - if mutation_was_accepted( + 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"); } @@ -2494,11 +2894,13 @@ fn partial_capture_malformed_bytes_and_rotation_family_fail_closed() { 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/WSUSCtrl.log"); - if mutation_was_accepted( + 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"); } @@ -2510,10 +2912,12 @@ fn partial_capture_malformed_bytes_and_rotation_family_fail_closed() { 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( + 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"); } diff --git a/docs/sccm/preparation/issue-330-software-update-point-corpus.md b/docs/sccm/preparation/issue-330-software-update-point-corpus.md index 34072258f..8928904e3 100644 --- a/docs/sccm/preparation/issue-330-software-update-point-corpus.md +++ b/docs/sccm/preparation/issue-330-software-update-point-corpus.md @@ -51,6 +51,14 @@ 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. @@ -136,6 +144,12 @@ 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. + ## Deferred implementation and validation Production `software_update_point.rs` implementation waits for the #318 API From cd9ac101919a9d2525c2678b1dc062472b1b2704 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:08:52 -0400 Subject: [PATCH 083/422] test(sccm): prepare correlation safety contracts --- .../tests/fixtures/sccm/correlation/README.md | 30 + .../adversarial-matrix.json | 524 +++++++++++++++++ .../sccm/correlation/pair-registry.json | 96 +++ .../adversarial-matrix.json | 525 +++++++++++++++++ .../shared/adversarial-matrix.json | 226 ++++++++ .../tests/sccm_correlation_contract.rs | 547 ++++++++++++++++++ .../issue-333-correlation-contracts.md | 49 ++ 7 files changed, 1997 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/content_distribution_point/adversarial-matrix.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/pair-registry.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/policy_management_point/adversarial-matrix.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/shared/adversarial-matrix.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs create mode 100644 docs/sccm/preparation/issue-333-correlation-contracts.md 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..7b703669e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md @@ -0,0 +1,30 @@ +# SCCM correlation adversarial contract fixtures + +These fixtures prepare issue #333's false-causality contract. They do not implement correlation, expose a production API, parse raw logs, or promote either pair to RuleValidated. + +`pair-registry.json` is deliberately non-executable. Policy to Management Point (`#321` to `#328`) and content to Distribution Point (`#322` to `#329`) are `contractPrepared`; their production flag, RuleValidated flag, and implementation module remain false/empty. Updates to SUP (`#323` to `#330`) is Candidate only and has no pair matrix or implementation permission. + +The shared matrix defines thirteen mandatory guards. Each first-pair matrix instantiates every guard in a pair-specific adversarial scenario: + +- missing client and missing server counterparts; +- same-time evidence without an exact key; +- conflicting exact keys; +- incompatible site, MP, DP, or role topology; +- content/profile version mismatch; +- unknown extraction profile; +- invalid timestamp offset; +- rotation-split and partial capture; +- unrelated terminal server failure; +- private-marker redaction; +- reordered input. + +Every adversarial expected result forbids `exactCorroborated`, caps confidence below High, preserves source findings, and uses stable reason/request/result identifiers. Reordered input A/B cases pin identical expected public projections and result contracts. + +Fixture references have explicit status: + +- `repo:` references point to already merged synthetic upstream fixture directories; +- `issue:#329:` references name pending DP scenarios without pretending they exist on the program baseline; +- `synthetic:` references describe future pair-local sanitized inputs; +- `absent` is an intentional missing counterpart, never proof of failure. + +No raw Windows path, live hostname, user, tenant, token, or database data belongs here. The only identity-shaped values are reserved synthetic private markers used to prove that expected public projections omit them. 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..bda006815 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/content_distribution_point/adversarial-matrix.json @@ -0,0 +1,524 @@ +{ + "schemaVersion": "1.0.0", + "workflow": "contentDistributionPoint", + "scenarios": [ + { + "scenarioId": "content-client-only", + "guardIds": [ + "missing-server-counterpart" + ], + "clientIssue": "#322", + "serverIssue": "#329", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/location-missing", + "serverFixtureRef": "absent", + "profileState": "validated", + "keyRelation": "exact", + "topology": "incomplete", + "timestampProvenance": "missing", + "coverage": "clientOnly", + "rotation": "complete", + "terminalRelation": "missing", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "counterpartRequested", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "contentDistributionPoint" + }, + "expected": { + "linkStrengthCeiling": "unlinked", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "server-counterpart-missing" + ], + "artifactRequests": [ + "server-dp-content" + ], + "deterministicResultId": "corr-contract:content:client-only" + } + }, + { + "scenarioId": "content-conflicting-key", + "guardIds": [ + "conflicting-exact-key" + ], + "clientIssue": "#322", + "serverIssue": "#329", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success", + "serverFixtureRef": "issue:#329:different-content", + "profileState": "validated", + "keyRelation": "conflicting", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "contradictory", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "incompatible", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "contentDistributionPoint" + }, + "expected": { + "linkStrengthCeiling": "incompatible", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "exact-key-conflict" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:content:conflicting-key" + } + }, + { + "scenarioId": "content-invalid-offset", + "guardIds": [ + "invalid-timestamp-offset" + ], + "clientIssue": "#322", + "serverIssue": "#329", + "clientFixtureRef": "synthetic:content-client-invalid-offset", + "serverFixtureRef": "issue:#329:content-available", + "profileState": "validated", + "keyRelation": "exact", + "topology": "compatible", + "timestampProvenance": "invalidOffset", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [], + "expectedPublicProjection": { + "ordering": "unavailable", + "outcome": "notCausal", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "contentDistributionPoint" + }, + "expected": { + "linkStrengthCeiling": "exactPartial", + "confidenceCeiling": "medium", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "invalid-offset" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:content:invalid-offset" + } + }, + { + "scenarioId": "content-partial-capture", + "guardIds": [ + "partial-capture" + ], + "clientIssue": "#322", + "serverIssue": "#329", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete", + "serverFixtureRef": "issue:#329:incomplete", + "profileState": "validated", + "keyRelation": "exact", + "topology": "incomplete", + "timestampProvenance": "missing", + "coverage": "partial", + "rotation": "complete", + "terminalRelation": "missing", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "coverageGap", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "contentDistributionPoint" + }, + "expected": { + "linkStrengthCeiling": "exactPartial", + "confidenceCeiling": "medium", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "partial-source-coverage" + ], + "artifactRequests": [ + "server-dp-content" + ], + "deterministicResultId": "corr-contract:content:partial-capture" + } + }, + { + "scenarioId": "content-redaction", + "guardIds": [ + "redaction-boundary" + ], + "clientIssue": "#322", + "serverIssue": "#329", + "clientFixtureRef": "synthetic:content-private-client-input", + "serverFixtureRef": "issue:#329:private-server-input", + "profileState": "validated", + "keyRelation": "exact", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [ + "LAB\\SyntheticDevice", + "https://dp.lab.invalid/content?sig=SYNTHETIC-SIGNATURE" + ], + "expectedPublicProjection": { + "clientHandle": "client-safe-001", + "dpHandle": "dp-safe-001", + "outcome": "notCausal", + "workflow": "contentDistributionPoint" + }, + "expected": { + "linkStrengthCeiling": "exactPartial", + "confidenceCeiling": "medium", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "redacted-contract-only" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:content:redaction" + } + }, + { + "scenarioId": "content-reordered-input-a", + "guardIds": [ + "reordered-input" + ], + "clientIssue": "#322", + "serverIssue": "#329", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success", + "serverFixtureRef": "issue:#329:content-available", + "profileState": "validated", + "keyRelation": "exact", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "contractOnly", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "contentDistributionPoint" + }, + "expected": { + "linkStrengthCeiling": "exactPartial", + "confidenceCeiling": "medium", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "upstream-facts-not-stable" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:content:reordered" + } + }, + { + "scenarioId": "content-reordered-input-b", + "guardIds": [ + "reordered-input" + ], + "clientIssue": "#322", + "serverIssue": "#329", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success", + "serverFixtureRef": "issue:#329:content-available", + "profileState": "validated", + "keyRelation": "exact", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "contractOnly", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "contentDistributionPoint" + }, + "expected": { + "linkStrengthCeiling": "exactPartial", + "confidenceCeiling": "medium", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "upstream-facts-not-stable" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:content:reordered" + } + }, + { + "scenarioId": "content-rotation-split", + "guardIds": [ + "partial-capture", + "rotation-split" + ], + "clientIssue": "#322", + "serverIssue": "#329", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary", + "serverFixtureRef": "issue:#329:rotation-boundary", + "profileState": "validated", + "keyRelation": "exact", + "topology": "incomplete", + "timestampProvenance": "missing", + "coverage": "partial", + "rotation": "split", + "terminalRelation": "missing", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "coverageGap", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "contentDistributionPoint" + }, + "expected": { + "linkStrengthCeiling": "exactPartial", + "confidenceCeiling": "medium", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "rotation-split" + ], + "artifactRequests": [ + "server-dp-content" + ], + "deterministicResultId": "corr-contract:content:rotation-split" + } + }, + { + "scenarioId": "content-same-time-no-key", + "guardIds": [ + "same-time-no-key" + ], + "clientIssue": "#322", + "serverIssue": "#329", + "clientFixtureRef": "synthetic:content-same-time-client", + "serverFixtureRef": "issue:#329:same-time-server", + "profileState": "validated", + "keyRelation": "missing", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "contradictory", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "candidateOnly", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "contentDistributionPoint" + }, + "expected": { + "linkStrengthCeiling": "candidate", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "time-only-not-causal" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:content:same-time-no-key" + } + }, + { + "scenarioId": "content-server-only", + "guardIds": [ + "missing-client-counterpart" + ], + "clientIssue": "#322", + "serverIssue": "#329", + "clientFixtureRef": "absent", + "serverFixtureRef": "issue:#329:distribution-failure", + "profileState": "validated", + "keyRelation": "exact", + "topology": "incomplete", + "timestampProvenance": "missing", + "coverage": "serverOnly", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "counterpartRequested", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "contentDistributionPoint" + }, + "expected": { + "linkStrengthCeiling": "unlinked", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "client-counterpart-missing" + ], + "artifactRequests": [ + "client-content-location" + ], + "deterministicResultId": "corr-contract:content:server-only" + } + }, + { + "scenarioId": "content-topology-mismatch", + "guardIds": [ + "incompatible-topology" + ], + "clientIssue": "#322", + "serverIssue": "#329", + "clientFixtureRef": "synthetic:content-client-dp-a", + "serverFixtureRef": "issue:#329:content-dp-b", + "profileState": "validated", + "keyRelation": "exact", + "topology": "incompatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "incompatible", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "contentDistributionPoint" + }, + "expected": { + "linkStrengthCeiling": "incompatible", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "dp-topology-mismatch" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:content:topology-mismatch" + } + }, + { + "scenarioId": "content-unknown-profile", + "guardIds": [ + "unknown-extraction-profile" + ], + "clientIssue": "#322", + "serverIssue": "#329", + "clientFixtureRef": "synthetic:content-client-unknown-profile", + "serverFixtureRef": "issue:#329:unknown-profile", + "profileState": "unknown", + "keyRelation": "exact", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "profileGap", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "contentDistributionPoint" + }, + "expected": { + "linkStrengthCeiling": "candidate", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "profile-unvalidated" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:content:unknown-profile" + } + }, + { + "scenarioId": "content-unrelated-terminal-error", + "guardIds": [ + "conflicting-exact-key", + "unrelated-terminal-error" + ], + "clientIssue": "#322", + "serverIssue": "#329", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success", + "serverFixtureRef": "issue:#329:unrelated-distribution-failure", + "profileState": "validated", + "keyRelation": "conflicting", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "unrelated", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "incompatible", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "contentDistributionPoint" + }, + "expected": { + "linkStrengthCeiling": "incompatible", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "unrelated-server-terminal" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:content:unrelated-terminal" + } + }, + { + "scenarioId": "content-version-mismatch", + "guardIds": [ + "version-mismatch" + ], + "clientIssue": "#322", + "serverIssue": "#329", + "clientFixtureRef": "synthetic:content-client-version-a", + "serverFixtureRef": "issue:#329:content-version-b", + "profileState": "versionMismatch", + "keyRelation": "versionMismatch", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "incompatible", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "contentDistributionPoint" + }, + "expected": { + "linkStrengthCeiling": "incompatible", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "content-version-mismatch" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:content:version-mismatch" + } + } + ] +} 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..b1ca955ad --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/pair-registry.json @@ -0,0 +1,96 @@ +{ + "schemaVersion": "1.0.0", + "pairs": [ + { + "pairId": "content-distribution-point", + "workflow": "contentDistributionPoint", + "clientIssue": "#322", + "serverIssue": "#329", + "state": "contractPrepared", + "productionEnabled": false, + "ruleValidated": false, + "implementationModule": null, + "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": [ + "#318 finding interface exact-head review pending", + "#322 public fact interface not implemented", + "#329 public fact interface not independently accepted" + ] + }, + { + "pairId": "policy-management-point", + "workflow": "policyManagementPoint", + "clientIssue": "#321", + "serverIssue": "#328", + "state": "contractPrepared", + "productionEnabled": false, + "ruleValidated": false, + "implementationModule": null, + "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": [ + "#318 finding interface exact-head review pending", + "#321 public fact interface not implemented", + "#328 public fact interface not implemented" + ] + }, + { + "pairId": "updates-software-update-point", + "workflow": "updatesSoftwareUpdatePoint", + "clientIssue": "#323", + "serverIssue": "#330", + "state": "candidate", + "productionEnabled": false, + "ruleValidated": false, + "implementationModule": null, + "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": [ + "#318 finding interface exact-head review pending", + "#323 source facts not independently accepted", + "#330 source facts not independently accepted", + "dedicated pair subplan not approved" + ] + } + ] +} 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..44ddb74fc --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/policy_management_point/adversarial-matrix.json @@ -0,0 +1,525 @@ +{ + "schemaVersion": "1.0.0", + "workflow": "policyManagementPoint", + "scenarios": [ + { + "scenarioId": "policy-client-only", + "guardIds": [ + "missing-server-counterpart" + ], + "clientIssue": "#321", + "serverIssue": "#328", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/request-auth-failure", + "serverFixtureRef": "absent", + "profileState": "validated", + "keyRelation": "exact", + "topology": "incomplete", + "timestampProvenance": "missing", + "coverage": "clientOnly", + "rotation": "complete", + "terminalRelation": "missing", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "counterpartRequested", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "policyManagementPoint" + }, + "expected": { + "linkStrengthCeiling": "unlinked", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "server-counterpart-missing" + ], + "artifactRequests": [ + "server-mp-auth", + "server-mp-policy" + ], + "deterministicResultId": "corr-contract:policy:client-only" + } + }, + { + "scenarioId": "policy-conflicting-key", + "guardIds": [ + "conflicting-exact-key" + ], + "clientIssue": "#321", + "serverIssue": "#328", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete", + "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key", + "profileState": "validated", + "keyRelation": "conflicting", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "contradictory", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "incompatible", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "policyManagementPoint" + }, + "expected": { + "linkStrengthCeiling": "incompatible", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "exact-key-conflict" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:policy:conflicting-key" + } + }, + { + "scenarioId": "policy-invalid-offset", + "guardIds": [ + "invalid-timestamp-offset" + ], + "clientIssue": "#321", + "serverIssue": "#328", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset", + "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy", + "profileState": "validated", + "keyRelation": "exact", + "topology": "compatible", + "timestampProvenance": "invalidOffset", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [], + "expectedPublicProjection": { + "ordering": "unavailable", + "outcome": "notCausal", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "policyManagementPoint" + }, + "expected": { + "linkStrengthCeiling": "exactPartial", + "confidenceCeiling": "medium", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "invalid-offset" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:policy:invalid-offset" + } + }, + { + "scenarioId": "policy-partial-capture", + "guardIds": [ + "partial-capture" + ], + "clientIssue": "#321", + "serverIssue": "#328", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete", + "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete", + "profileState": "validated", + "keyRelation": "exact", + "topology": "incomplete", + "timestampProvenance": "missing", + "coverage": "partial", + "rotation": "complete", + "terminalRelation": "missing", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "coverageGap", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "policyManagementPoint" + }, + "expected": { + "linkStrengthCeiling": "exactPartial", + "confidenceCeiling": "medium", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "partial-source-coverage" + ], + "artifactRequests": [ + "server-mp-policy" + ], + "deterministicResultId": "corr-contract:policy:partial-capture" + } + }, + { + "scenarioId": "policy-redaction", + "guardIds": [ + "redaction-boundary" + ], + "clientIssue": "#321", + "serverIssue": "#328", + "clientFixtureRef": "synthetic:policy-private-client-input", + "serverFixtureRef": "synthetic:policy-private-server-input", + "profileState": "validated", + "keyRelation": "exact", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [ + "LAB\\SyntheticUser", + "https://mp.lab.invalid/policy?token=SYNTHETIC-TOKEN" + ], + "expectedPublicProjection": { + "clientHandle": "client-safe-001", + "outcome": "notCausal", + "serverHandle": "mp-safe-001", + "workflow": "policyManagementPoint" + }, + "expected": { + "linkStrengthCeiling": "exactPartial", + "confidenceCeiling": "medium", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "redacted-contract-only" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:policy:redaction" + } + }, + { + "scenarioId": "policy-reordered-input-a", + "guardIds": [ + "reordered-input" + ], + "clientIssue": "#321", + "serverIssue": "#328", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete", + "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy", + "profileState": "validated", + "keyRelation": "exact", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "contractOnly", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "policyManagementPoint" + }, + "expected": { + "linkStrengthCeiling": "exactPartial", + "confidenceCeiling": "medium", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "upstream-facts-not-stable" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:policy:reordered" + } + }, + { + "scenarioId": "policy-reordered-input-b", + "guardIds": [ + "reordered-input" + ], + "clientIssue": "#321", + "serverIssue": "#328", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete", + "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy", + "profileState": "validated", + "keyRelation": "exact", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "contractOnly", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "policyManagementPoint" + }, + "expected": { + "linkStrengthCeiling": "exactPartial", + "confidenceCeiling": "medium", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "upstream-facts-not-stable" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:policy:reordered" + } + }, + { + "scenarioId": "policy-rotation-split", + "guardIds": [ + "partial-capture", + "rotation-split" + ], + "clientIssue": "#321", + "serverIssue": "#328", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/rotation-split", + "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary", + "profileState": "validated", + "keyRelation": "exact", + "topology": "incomplete", + "timestampProvenance": "missing", + "coverage": "partial", + "rotation": "split", + "terminalRelation": "missing", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "coverageGap", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "policyManagementPoint" + }, + "expected": { + "linkStrengthCeiling": "exactPartial", + "confidenceCeiling": "medium", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "rotation-split" + ], + "artifactRequests": [ + "server-mp-policy" + ], + "deterministicResultId": "corr-contract:policy:rotation-split" + } + }, + { + "scenarioId": "policy-same-time-no-key", + "guardIds": [ + "same-time-no-key" + ], + "clientIssue": "#321", + "serverIssue": "#328", + "clientFixtureRef": "synthetic:policy-same-time-client", + "serverFixtureRef": "synthetic:policy-same-time-server", + "profileState": "validated", + "keyRelation": "missing", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "contradictory", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "candidateOnly", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "policyManagementPoint" + }, + "expected": { + "linkStrengthCeiling": "candidate", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "time-only-not-causal" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:policy:same-time-no-key" + } + }, + { + "scenarioId": "policy-server-only", + "guardIds": [ + "missing-client-counterpart" + ], + "clientIssue": "#321", + "serverIssue": "#328", + "clientFixtureRef": "absent", + "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure", + "profileState": "validated", + "keyRelation": "exact", + "topology": "incomplete", + "timestampProvenance": "missing", + "coverage": "serverOnly", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "counterpartRequested", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "policyManagementPoint" + }, + "expected": { + "linkStrengthCeiling": "unlinked", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "client-counterpart-missing" + ], + "artifactRequests": [ + "client-policy-agent" + ], + "deterministicResultId": "corr-contract:policy:server-only" + } + }, + { + "scenarioId": "policy-topology-mismatch", + "guardIds": [ + "incompatible-topology" + ], + "clientIssue": "#321", + "serverIssue": "#328", + "clientFixtureRef": "synthetic:policy-client-site-lab", + "serverFixtureRef": "synthetic:policy-server-site-other", + "profileState": "validated", + "keyRelation": "exact", + "topology": "incompatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "incompatible", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "policyManagementPoint" + }, + "expected": { + "linkStrengthCeiling": "incompatible", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "site-or-mp-mismatch" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:policy:topology-mismatch" + } + }, + { + "scenarioId": "policy-unknown-profile", + "guardIds": [ + "unknown-extraction-profile" + ], + "clientIssue": "#321", + "serverIssue": "#328", + "clientFixtureRef": "synthetic:policy-client-unknown-profile", + "serverFixtureRef": "synthetic:policy-server-unknown-profile", + "profileState": "unknown", + "keyRelation": "exact", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "profileGap", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "policyManagementPoint" + }, + "expected": { + "linkStrengthCeiling": "candidate", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "profile-unvalidated" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:policy:unknown-profile" + } + }, + { + "scenarioId": "policy-unrelated-terminal-error", + "guardIds": [ + "conflicting-exact-key", + "unrelated-terminal-error" + ], + "clientIssue": "#321", + "serverIssue": "#328", + "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete", + "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure", + "profileState": "validated", + "keyRelation": "conflicting", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "unrelated", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "incompatible", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "policyManagementPoint" + }, + "expected": { + "linkStrengthCeiling": "incompatible", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "unrelated-server-terminal" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:policy:unrelated-terminal" + } + }, + { + "scenarioId": "policy-version-mismatch", + "guardIds": [ + "version-mismatch" + ], + "clientIssue": "#321", + "serverIssue": "#328", + "clientFixtureRef": "synthetic:policy-client-profile-v1", + "serverFixtureRef": "synthetic:policy-server-profile-v2", + "profileState": "versionMismatch", + "keyRelation": "versionMismatch", + "topology": "compatible", + "timestampProvenance": "usable", + "coverage": "complete", + "rotation": "complete", + "terminalRelation": "corroborating", + "privateInputMarkers": [], + "expectedPublicProjection": { + "outcome": "incompatible", + "schemaVersion": "1.0.0", + "sourceFindingsPreserved": true, + "workflow": "policyManagementPoint" + }, + "expected": { + "linkStrengthCeiling": "incompatible", + "confidenceCeiling": "low", + "highConfidenceCauseAllowed": false, + "exactCorroboratedAllowed": false, + "sourceFindingsMutable": false, + "reasonCodes": [ + "profile-version-mismatch" + ], + "artifactRequests": [], + "deterministicResultId": "corr-contract:policy:version-mismatch" + } + } + ] +} 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..e0c4041ba --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/shared/adversarial-matrix.json @@ -0,0 +1,226 @@ +{ + "schemaVersion": "1.0.0", + "guards": [ + { + "guardId": "conflicting-exact-key", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "incompatibilityReason", + "sourceLocalResults" + ] + }, + { + "guardId": "incompatible-topology", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "incompatibilityReason", + "sourceLocalResults" + ] + }, + { + "guardId": "invalid-timestamp-offset", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "orderingUnavailable", + "sourceLocalResults" + ] + }, + { + "guardId": "missing-client-counterpart", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "clientArtifactRequest", + "serverLocalResults" + ] + }, + { + "guardId": "missing-server-counterpart", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "clientLocalResults", + "serverArtifactRequest" + ] + }, + { + "guardId": "partial-capture", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "coverageGap", + "sourceLocalResults" + ] + }, + { + "guardId": "redaction-boundary", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "publicSafeHandles", + "redactedProjection" + ] + }, + { + "guardId": "reordered-input", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "deterministicSerialization", + "sourceLocalResults" + ] + }, + { + "guardId": "rotation-split", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "coverageGap", + "sourceLocalResults" + ] + }, + { + "guardId": "same-time-no-key", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "candidateSymptom", + "sourceLocalResults" + ] + }, + { + "guardId": "unknown-extraction-profile", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "profileGap", + "sourceLocalResults" + ] + }, + { + "guardId": "unrelated-terminal-error", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "sourceLocalResults", + "unlinkedTerminalEvidence" + ] + }, + { + "guardId": "version-mismatch", + "appliesTo": [ + "contentDistributionPoint", + "policyManagementPoint" + ], + "forbiddenStrengths": [ + "exactCorroborated" + ], + "forbiddenConfidences": [ + "high" + ], + "requiredOutputs": [ + "incompatibilityReason", + "sourceLocalResults" + ] + } + ] +} 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..21e065d70 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -0,0 +1,547 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use serde_json::Value; + +const CONTRACT_SCHEMA_VERSION: &str = "1.0.0"; +const WORKFLOWS: [&str; 2] = ["contentDistributionPoint", "policyManagementPoint"]; +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 POLICY_SCENARIOS: [&str; 14] = [ + "policy-client-only", + "policy-conflicting-key", + "policy-invalid-offset", + "policy-partial-capture", + "policy-redaction", + "policy-reordered-input-a", + "policy-reordered-input-b", + "policy-rotation-split", + "policy-same-time-no-key", + "policy-server-only", + "policy-topology-mismatch", + "policy-unknown-profile", + "policy-unrelated-terminal-error", + "policy-version-mismatch", +]; +const CONTENT_SCENARIOS: [&str; 14] = [ + "content-client-only", + "content-conflicting-key", + "content-invalid-offset", + "content-partial-capture", + "content-redaction", + "content-reordered-input-a", + "content-reordered-input-b", + "content-rotation-split", + "content-same-time-no-key", + "content-server-only", + "content-topology-mismatch", + "content-unknown-profile", + "content-unrelated-terminal-error", + "content-version-mismatch", +]; + +#[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 ScenarioMatrix { + schema_version: String, + workflow: String, + scenarios: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ScenarioContract { + scenario_id: String, + guard_ids: Vec, + client_issue: String, + server_issue: String, + client_fixture_ref: String, + server_fixture_ref: String, + profile_state: ProfileState, + key_relation: KeyRelation, + topology: TopologyState, + timestamp_provenance: TimestampState, + coverage: CoverageState, + rotation: RotationState, + terminal_relation: TerminalRelation, + private_input_markers: Vec, + expected_public_projection: Value, + expected: ExpectedCeiling, +} + +#[derive(Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +enum ProfileState { + Validated, + Unknown, + VersionMismatch, +} + +#[derive(Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +enum KeyRelation { + Exact, + Conflicting, + Missing, + VersionMismatch, +} + +#[derive(Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +enum TopologyState { + Compatible, + Incomplete, + Incompatible, +} + +#[derive(Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +enum TimestampState { + Usable, + Missing, + InvalidOffset, +} + +#[derive(Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +enum CoverageState { + Complete, + ClientOnly, + ServerOnly, + Partial, +} + +#[derive(Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +enum RotationState { + Complete, + Split, +} + +#[derive(Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +enum TerminalRelation { + Corroborating, + Missing, + Contradictory, + Unrelated, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpectedCeiling { + link_strength_ceiling: String, + confidence_ceiling: String, + high_confidence_cause_allowed: bool, + exact_corroborated_allowed: bool, + source_findings_mutable: bool, + reason_codes: Vec, + artifact_requests: Vec, + deterministic_result_id: String, +} + +#[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: PairState, + production_enabled: bool, + rule_validated: bool, + implementation_module: Option, + required_guard_ids: Vec, + blockers: Vec, +} + +#[derive(Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +enum PairState { + ContractPrepared, + Candidate, +} + +fn corpus_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/correlation") +} + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("parser crate has a repository root") + .to_path_buf() +} + +fn read_typed Deserialize<'de>>(path: &Path) -> T { + 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!("{} is typed JSON: {error}", path.display())) +} + +fn is_sorted_unique(values: &[String]) -> bool { + values.windows(2).all(|pair| pair[0] < pair[1]) +} + +fn validate_issue(value: &str) -> bool { + value.strip_prefix('#').is_some_and(|digits| { + !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +fn validate_fixture_ref(value: &str, issue: &str) -> bool { + if value == "absent" { + return true; + } + if let Some(path) = value.strip_prefix("repo:") { + return !path.contains("..") && repo_root().join(path).is_dir(); + } + if let Some(synthetic_id) = value.strip_prefix("synthetic:") { + return !synthetic_id.is_empty() + && synthetic_id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); + } + if let Some(pending) = value.strip_prefix("issue:") { + return pending + .strip_prefix(issue) + .and_then(|rest| rest.strip_prefix(':')) + .is_some_and(|scenario| { + !scenario.is_empty() + && scenario.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' + }) + }); + } + false +} + +fn assert_matrix_contract( + matrix: &ScenarioMatrix, + expected_workflow: &str, + expected_scenarios: &[&str], + client_issue: &str, + server_issue: &str, +) { + assert_eq!(matrix.schema_version, CONTRACT_SCHEMA_VERSION); + assert_eq!(matrix.workflow, expected_workflow); + let scenario_ids = matrix + .scenarios + .iter() + .map(|scenario| scenario.scenario_id.as_str()) + .collect::>(); + assert_eq!(scenario_ids, expected_scenarios); + + let mut exercised_guards = BTreeSet::new(); + for scenario in &matrix.scenarios { + assert_eq!( + scenario.client_issue, client_issue, + "{}", + scenario.scenario_id + ); + assert_eq!( + scenario.server_issue, server_issue, + "{}", + scenario.scenario_id + ); + assert!(validate_issue(&scenario.client_issue)); + assert!(validate_issue(&scenario.server_issue)); + assert!( + validate_fixture_ref(&scenario.client_fixture_ref, &scenario.client_issue), + "{}: invalid client fixture ref {}", + scenario.scenario_id, + scenario.client_fixture_ref + ); + assert!( + validate_fixture_ref(&scenario.server_fixture_ref, &scenario.server_issue), + "{}: invalid server fixture ref {}", + scenario.scenario_id, + scenario.server_fixture_ref + ); + assert!( + !scenario.guard_ids.is_empty() && is_sorted_unique(&scenario.guard_ids), + "{}: guard IDs must be nonempty, sorted, and unique", + scenario.scenario_id + ); + assert!( + scenario + .guard_ids + .iter() + .all(|guard| GUARD_IDS.contains(&guard.as_str())), + "{}: unknown guard", + scenario.scenario_id + ); + exercised_guards.extend(scenario.guard_ids.iter().map(String::as_str)); + assert!( + !scenario.expected.high_confidence_cause_allowed, + "{}: adversarial fixture cannot permit high-confidence cause", + scenario.scenario_id + ); + assert!( + !scenario.expected.exact_corroborated_allowed, + "{}: adversarial fixture cannot permit ExactCorroborated", + scenario.scenario_id + ); + assert!(!scenario.expected.source_findings_mutable); + assert_ne!(scenario.expected.link_strength_ceiling, "exactCorroborated"); + assert_ne!(scenario.expected.confidence_ceiling, "high"); + assert!(["candidate", "exactPartial", "incompatible", "unlinked"] + .contains(&scenario.expected.link_strength_ceiling.as_str())); + assert!(["low", "medium"].contains(&scenario.expected.confidence_ceiling.as_str())); + assert!( + !scenario.expected.reason_codes.is_empty() + && is_sorted_unique(&scenario.expected.reason_codes) + ); + assert!(is_sorted_unique(&scenario.expected.artifact_requests)); + assert!(!scenario.expected.deterministic_result_id.is_empty()); + + let public_json = serde_json::to_string(&scenario.expected_public_projection) + .expect("expected public projection serializes"); + for marker in &scenario.private_input_markers { + assert!( + !public_json.contains(marker), + "{}: private marker leaked into expected public projection", + scenario.scenario_id + ); + } + assert_eq!( + scenario.private_input_markers.is_empty(), + !scenario + .guard_ids + .contains(&"redaction-boundary".to_owned()), + "{}: private markers and the redaction guard must be declared together", + scenario.scenario_id + ); + + if scenario.profile_state != ProfileState::Validated { + assert!(scenario + .guard_ids + .iter() + .any(|guard| guard == "unknown-extraction-profile" || guard == "version-mismatch")); + } + if scenario.key_relation == KeyRelation::Missing { + assert!(scenario.guard_ids.contains(&"same-time-no-key".to_owned())); + } + if scenario.key_relation == KeyRelation::Conflicting { + assert!(scenario + .guard_ids + .contains(&"conflicting-exact-key".to_owned())); + } + if scenario.key_relation == KeyRelation::VersionMismatch { + assert!(scenario.guard_ids.contains(&"version-mismatch".to_owned())); + } + if scenario.topology == TopologyState::Incompatible { + assert!(scenario + .guard_ids + .contains(&"incompatible-topology".to_owned())); + } + if scenario.timestamp_provenance == TimestampState::InvalidOffset { + assert!(scenario + .guard_ids + .contains(&"invalid-timestamp-offset".to_owned())); + } + if scenario.coverage == CoverageState::ClientOnly { + assert!(scenario + .guard_ids + .contains(&"missing-server-counterpart".to_owned())); + assert!(!scenario.expected.artifact_requests.is_empty()); + } + if scenario.coverage == CoverageState::ServerOnly { + assert!(scenario + .guard_ids + .contains(&"missing-client-counterpart".to_owned())); + assert!(!scenario.expected.artifact_requests.is_empty()); + } + if scenario.coverage == CoverageState::Partial { + assert!(scenario.guard_ids.contains(&"partial-capture".to_owned())); + } + if scenario.rotation == RotationState::Split { + assert!(scenario.guard_ids.contains(&"rotation-split".to_owned())); + } + if scenario.terminal_relation == TerminalRelation::Unrelated { + assert!(scenario + .guard_ids + .contains(&"unrelated-terminal-error".to_owned())); + } + } + assert_eq!( + exercised_guards, + GUARD_IDS.into_iter().collect(), + "{expected_workflow}: every shared guard needs a pair-specific adversarial scenario" + ); +} + +#[test] +fn correlation_preparation_contains_no_production_module() { + assert!( + !PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src/sccm/correlation") + .exists(), + "#333 preparation must not add production correlation before upstream facts stabilize" + ); +} + +#[test] +fn shared_false_causality_guards_are_exact_and_pair_complete() { + let matrix: GuardMatrix = read_typed(&corpus_root().join("shared/adversarial-matrix.json")); + assert_eq!(matrix.schema_version, CONTRACT_SCHEMA_VERSION); + let guard_ids = matrix + .guards + .iter() + .map(|guard| guard.guard_id.as_str()) + .collect::>(); + assert_eq!(guard_ids, GUARD_IDS); + + for guard in matrix.guards { + assert_eq!(guard.applies_to, WORKFLOWS); + assert_eq!(guard.forbidden_strengths, ["exactCorroborated"]); + assert_eq!(guard.forbidden_confidences, ["high"]); + assert!(!guard.required_outputs.is_empty()); + assert!(is_sorted_unique(&guard.required_outputs)); + } +} + +#[test] +fn policy_to_management_point_adversarial_matrix_is_conservative() { + let matrix: ScenarioMatrix = + read_typed(&corpus_root().join("policy_management_point/adversarial-matrix.json")); + assert_matrix_contract( + &matrix, + "policyManagementPoint", + &POLICY_SCENARIOS, + "#321", + "#328", + ); +} + +#[test] +fn content_to_distribution_point_adversarial_matrix_is_conservative() { + let matrix: ScenarioMatrix = + read_typed(&corpus_root().join("content_distribution_point/adversarial-matrix.json")); + assert_matrix_contract( + &matrix, + "contentDistributionPoint", + &CONTENT_SCENARIOS, + "#322", + "#329", + ); +} + +#[test] +fn reordered_contracts_pin_identical_expected_results() { + for path in [ + "policy_management_point/adversarial-matrix.json", + "content_distribution_point/adversarial-matrix.json", + ] { + let matrix: ScenarioMatrix = read_typed(&corpus_root().join(path)); + let reordered = matrix + .scenarios + .iter() + .filter(|scenario| scenario.guard_ids.contains(&"reordered-input".to_owned())) + .collect::>(); + assert_eq!(reordered.len(), 2, "{path}"); + assert_eq!(reordered[0].expected, reordered[1].expected, "{path}"); + assert_eq!( + reordered[0].expected_public_projection, reordered[1].expected_public_projection, + "{path}" + ); + } +} + +#[test] +fn pair_registry_is_non_executable_and_expansion_is_gated() { + let registry: PairRegistry = read_typed(&corpus_root().join("pair-registry.json")); + assert_eq!(registry.schema_version, CONTRACT_SCHEMA_VERSION); + let pair_ids = registry + .pairs + .iter() + .map(|pair| pair.pair_id.as_str()) + .collect::>(); + assert_eq!( + pair_ids, + [ + "content-distribution-point", + "policy-management-point", + "updates-software-update-point", + ] + ); + + let mut workflow_states = BTreeMap::new(); + for pair in registry.pairs { + assert!(validate_issue(&pair.client_issue)); + assert!(validate_issue(&pair.server_issue)); + assert!(!pair.production_enabled); + assert!(!pair.rule_validated); + assert!(pair.implementation_module.is_none()); + assert_eq!(pair.required_guard_ids, GUARD_IDS); + assert!(!pair.blockers.is_empty()); + assert!(is_sorted_unique(&pair.blockers)); + workflow_states.insert(pair.workflow, pair.state); + } + assert_eq!( + workflow_states + .keys() + .map(String::as_str) + .collect::>(), + [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint", + ] + .into_iter() + .collect() + ); + assert_eq!( + workflow_states["contentDistributionPoint"], + PairState::ContractPrepared + ); + assert_eq!( + workflow_states["policyManagementPoint"], + PairState::ContractPrepared + ); + assert_eq!( + workflow_states["updatesSoftwareUpdatePoint"], + PairState::Candidate + ); +} 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. From 6e2d2f130b1699ebc4a9f20f3e37f18cf4a0c1d0 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:12:16 -0400 Subject: [PATCH 084/422] test(sccm): tighten advanced role admission --- .../sccm_server_advanced_roles_catalog.rs | 100 ++++++++++++++++-- docs/sccm/source-catalog/advanced-roles.md | 5 +- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs b/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs index 27ab3d0c3..0c7e1dea0 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs @@ -452,6 +452,15 @@ fn validate_card(card: &SourceCard) -> Validation { { 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()); + } _ => {} } @@ -518,6 +527,12 @@ fn validate_card(card: &SourceCard) -> Validation { } 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, @@ -542,8 +557,9 @@ fn validate_card(card: &SourceCard) -> Validation { issues.sort(); issues.dedup(); - let admitted_to_semantic_catalog = - issues.is_empty() && matches!(card.promotion.state, PromotionState::RuleValidated); + let admitted_to_semantic_catalog = issues.is_empty() + && matches!(card.promotion.state, PromotionState::RuleValidated) + && card.supersession.state == SupersessionState::Active; Validation { valid: issues.is_empty(), admitted_to_semantic_catalog, @@ -551,6 +567,28 @@ fn validate_card(card: &SourceCard) -> Validation { } } +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()); + } + validation.issues.sort(); + validation.issues.dedup(); + validation.valid = validation.issues.is_empty(); + validation.admitted_to_semantic_catalog = validation.valid + && matches!(card.promotion.state, PromotionState::RuleValidated) + && card.supersession.state == SupersessionState::Active; + validation +} + fn validate_path(path: &Path) -> Validation { match load_card(path) { Ok(card) => validate_card(&card), @@ -585,11 +623,24 @@ fn advanced_role_source_card_inventory_is_exact_and_sorted() { #[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 mut card_ids = Vec::new(); - for filename in SOURCE_CARDS { - let path = root.join(filename); - let card = load_card(&path).unwrap_or_else(|error| panic!("{filename}: {error}")); - let validation = validate_card(&card); + for (filename, card) in SOURCE_CARDS.into_iter().zip(cards) { + let validation = validate_card_with_inventory(&card, &inventory); assert!( validation.valid, "{filename}: {}", @@ -693,14 +744,38 @@ fn deprecation_requires_an_explicit_successor_and_never_panics() { .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 valid = validate_card(&card); + 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(&"deprecatedCardRequiresSuccessor".to_owned())); + .contains(&"supersessionSuccessorMissing".to_owned())); + assert!(valid.valid, "an existing successor keeps the card valid"); assert!( !valid.admitted_to_semantic_catalog, - "deprecation metadata cannot promote a candidate source" + "deprecation metadata cannot admit even a RuleValidated source" ); } @@ -737,4 +812,11 @@ fn only_a_fully_linked_rule_validated_card_is_semantically_admitted() { 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/docs/sccm/source-catalog/advanced-roles.md b/docs/sccm/source-catalog/advanced-roles.md index b68215fa6..1e876a3b6 100644 --- a/docs/sccm/source-catalog/advanced-roles.md +++ b/docs/sccm/source-catalog/advanced-roles.md @@ -43,7 +43,7 @@ Candidate names must be confirmed against configured role provenance before prom ## 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 an explicit successor, and supersession metadata cannot promote a card. +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. Supersession metadata cannot promote a card. The synthetic catalog-fixture matrix proves: @@ -52,7 +52,8 @@ The synthetic catalog-fixture matrix proves: - a candidate cannot declare a production reducer or diagnostic capabilities; - high-sensitivity data cannot disable redaction or project raw sensitive fields; - unknown parser and promotion values are retained for review and rejected; -- deprecation without an explicit successor is rejected. +- RuleValidated admission still requires validated, nonempty, deterministic key kinds; +- deprecation without an existing catalog successor is rejected, and deprecated cards remain outside semantic admission. ## Native validation boundary From 5c0fda213686fd9ddb12a29723c601f675836567 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:23:15 -0400 Subject: [PATCH 085/422] fix(sccm): close finding contract review gaps --- .../cmtraceopen-parser/src/sccm/evidence.rs | 31 ++-- .../cmtraceopen-parser/src/sccm/findings.rs | 8 +- .../tests/sccm_spine_contract.rs | 135 +++++++++++++++++- 3 files changed, 164 insertions(+), 10 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/evidence.rs b/crates/cmtraceopen-parser/src/sccm/evidence.rs index 75134fb3d..63fd75145 100644 --- a/crates/cmtraceopen-parser/src/sccm/evidence.rs +++ b/crates/cmtraceopen-parser/src/sccm/evidence.rs @@ -28,6 +28,10 @@ fn sensitive_message_label_re() -> &'static Regex { | (?:client|access|refresh|id|device|session)[\x20_-]?token | api[\x20_-]?key | account[\x20_-]?key + | samaccountname + | accountname + | localuser + | identity | user[\x20_-]?principal[\x20_-]?name | credential | password @@ -48,8 +52,10 @@ fn sensitive_message_label_re() -> &'static Regex { 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") + 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") }) } @@ -57,7 +63,14 @@ fn windows_user_path_identity_re() -> &'static Regex { static CELL: OnceLock = OnceLock::new(); CELL.get_or_init(|| { Regex::new( - r"(?i)(?:^|[\\/])users[\\/](?P(?:NT AUTHORITY|[A-Z0-9][A-Z0-9._-]*|\.)\\[A-Z0-9][A-Z0-9._$-]*\b)", + 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") }) @@ -76,11 +89,13 @@ fn email_identity_re() -> &'static Regex { /// 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); - let projected = redact_email_identities(&identities_redacted); - - format!("[{PUBLIC_MESSAGE_PROFILE}] {projected}") + redact_email_identities(&identities_redacted) } fn redact_sensitive_segments(value: &str) -> String { @@ -223,8 +238,8 @@ impl SccmRawEvidenceSnapshot { evidence_id: self.evidence_id.clone(), reference: self.reference.clone(), role: self.role.clone(), - component: self.component.clone(), - ccm_source_file: self.ccm_source_file.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 diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 2539f9ceb..aefb976e4 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -413,7 +413,10 @@ impl SccmFinding { return Err(SccmFindingValidationError::MissingCoverageGap); } - if self.evidence.is_empty() && self.coverage_gaps.is_empty() { + if self.evidence.is_empty() + && (self.coverage_gaps.is_empty() + || self.class != SccmFindingClass::InsufficientEvidence) + { return Err(SccmFindingValidationError::MissingEvidenceOrCoverageGap); } @@ -1580,6 +1583,9 @@ fn is_passive_auxiliary(token: &str) -> bool { | "be" | "been" | "being" + | "had" + | "has" + | "have" | "is" | "must" | "need" diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 6486c5ccc..9f9c4f986 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -382,7 +382,7 @@ const REVIEW_UNRECOGNIZED_CONFIRMATION_REQUEST_REASONS: [&str; 10] = [ "Confirm PolicyAgent.log for fetching credentials.", ]; -const REVIEW_PASSIVE_UNBOUNDED_CONFIRMATION_REASONS: [&str; 8] = [ +const REVIEW_PASSIVE_UNBOUNDED_CONFIRMATION_REASONS: [&str; 12] = [ "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.", @@ -391,6 +391,10 @@ const REVIEW_PASSIVE_UNBOUNDED_CONFIRMATION_REASONS: [&str; 8] = [ "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.", ]; const REVIEW_EXACT_MP_ARTIFACT_REQUESTS: [(&str, &str); 5] = [ @@ -2650,6 +2654,91 @@ fn finding_evidence_less_claims_are_rejected() { ); } +#[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::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( @@ -4710,6 +4799,50 @@ fn evidence_public_message_projection_redacts_path_adjacent_windows_identities() ); } +#[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!( From e3eb43a6113718f71309df26a5665e7e257832d2 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:24:50 -0400 Subject: [PATCH 086/422] test(sccm): close SUP mutation asset schema --- ..._software_update_point_fixture_contract.rs | 92 +++++++++++++++++++ .../issue-330-software-update-point-corpus.md | 7 +- 2 files changed, 98 insertions(+), 1 deletion(-) 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 index 4d3ca41a1..2c4cba027 100644 --- 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 @@ -1975,6 +1975,48 @@ fn mutation_was_accepted_with_asset( 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(); @@ -2063,6 +2105,50 @@ fn every_scenario_evidence_asset_is_manifest_and_coverage_closed() { } } +#[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(); @@ -2072,6 +2158,12 @@ fn mutation_assets_have_an_explicit_separate_test_contract() { .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); diff --git a/docs/sccm/preparation/issue-330-software-update-point-corpus.md b/docs/sccm/preparation/issue-330-software-update-point-corpus.md index 8928904e3..3a41c8f15 100644 --- a/docs/sccm/preparation/issue-330-software-update-point-corpus.md +++ b/docs/sccm/preparation/issue-330-software-update-point-corpus.md @@ -148,7 +148,12 @@ 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. +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 From 98a72fe8db7315497935d7382a5572e1f63a3f61 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:41:08 -0400 Subject: [PATCH 087/422] test(sccm): harden DP executable probe --- ...ver_distribution_point_fixture_contract.rs | 317 ++++++++++++++++-- .../issue-329-distribution-point-corpus.md | 19 +- 2 files changed, 295 insertions(+), 41 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index fe58b8f12..daca035ed 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -30,6 +30,7 @@ const STATE_CHAIN: &[&str] = &[ ]; const EXACT_PROFILE: &str = "dp-server-5.00.test-v1"; +const EXACT_SOURCE_VERSION: &str = "5.00.TEST.0001"; const EXACT_SITE: &str = "LAB"; const EXACT_DP: &str = "safe:dp:lab-dp-01"; const EXACT_SITE_SERVER: &str = "safe:server:lab-pri-01"; @@ -108,16 +109,20 @@ fn coverage_from_manifest(state: &str) -> Result { 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()) - .map(SccmRotation::Numbered) - .ok_or_else(|| "numbered rotation requires a u32 value".to_owned()), - "timestamped" => required_string(rotation, "value", "rotation") - .map(str::to_owned) - .map(SccmRotation::Timestamped), + "current" if rotation.get("value").is_none() => Ok(SccmRotation::Current), + "current" => Err("current rotation must not contain a value".to_owned()), + "lo_" if rotation.get("value").is_none() => Ok(SccmRotation::LoUnderscore), + "lo_" => Err("lo_ rotation must not contain a value".to_owned()), + "numbered" => serde_json::from_value(json!({ + "kind": "numbered", + "value": rotation["value"].clone(), + })) + .map_err(|error| format!("numbered rotation is noncanonical: {error}")), + "timestamped" => serde_json::from_value(json!({ + "kind": "timestamped", + "value": rotation["value"].clone(), + })) + .map_err(|error| format!("timestamped rotation is noncanonical: {error}")), other => Err(format!("unsupported fixture rotation {other}")), } } @@ -233,18 +238,45 @@ fn parse_fixture_fields(message: &str) -> Result, Strin Ok(fields) } +fn path_segment_is_safe(segment: &str) -> bool { + !segment.is_empty() + && !matches!(segment, "." | "..") + && segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn segmented_path_is_safe(path: &str) -> bool { + !path.is_empty() && !path.contains('\\') && path.split('/').all(path_segment_is_safe) +} + 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('/').any(|segment| segment == "..") + relative_path + .strip_prefix("evidence/") + .is_some_and(segmented_path_is_safe) && relative_path .rsplit('/') .next() .is_some_and(|candidate| candidate == basename) } +fn sanitized_source_path_is_bounded(source_path: &str) -> bool { + source_path + .strip_prefix("SYNTHETIC://") + .is_some_and(segmented_path_is_safe) +} + +fn path_fingerprint_is_safe(path_fingerprint: &str) -> bool { + path_fingerprint + .strip_prefix("synthetic:") + .is_some_and(|suffix| { + !suffix.is_empty() + && suffix + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + }) +} + fn validate_manifest( scenario_root: &std::path::Path, manifest: &Value, @@ -324,10 +356,25 @@ fn validate_manifest( .into_iter() .collect::>(); - let roles = manifest["topology"]["rolesObserved"] - .as_array() - .map(|values| values.iter().filter_map(Value::as_str).collect::>()) - .unwrap_or_default(); + let mut roles = Vec::new(); + match required_array(&manifest["topology"], "rolesObserved", "topology") { + Ok(values) => { + for value in values { + match value.as_str() { + Some(role) if matches!(role, "distributionPoint" | "siteServer") => { + roles.push(role); + } + Some(role) => { + failures.push(format!("rolesObserved contains unsupported role {role}")) + } + None => failures.push( + "rolesObserved entries must be strings in the allowed role set".to_owned(), + ), + } + } + } + Err(error) => failures.push(error), + } let mut sorted_roles = roles.clone(); sorted_roles.sort_unstable(); sorted_roles.dedup(); @@ -371,6 +418,7 @@ fn validate_manifest( let mut evidence_by_reference = BTreeMap::new(); let mut relative_paths = BTreeSet::new(); let mut physical_source_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, @@ -492,11 +540,16 @@ fn validate_manifest( } let path_fingerprint = artifact["pathFingerprint"].as_str(); let sanitized_source_path = artifact["sanitizedSourcePath"].as_str(); - if !path_fingerprint.is_some_and(|value| value.starts_with("synthetic:")) - || !sanitized_source_path.is_some_and(|value| value.starts_with("SYNTHETIC://")) + if !path_fingerprint.is_some_and(path_fingerprint_is_safe) + || !sanitized_source_path.is_some_and(sanitized_source_path_is_bounded) { failures.push(format!("{artifact_id} leaks or omits path provenance")); } + if path_fingerprint.is_some_and(|value| !path_fingerprints.insert(value.to_owned())) { + failures.push(format!( + "{artifact_id} duplicates another physical path fingerprint" + )); + } let rotation_kind = artifact["rotation"]["kind"].as_str(); let rotation_value = artifact["rotation"]["value"] .as_str() @@ -526,9 +579,7 @@ fn validate_manifest( } } if artifact["sourceKind"] != "ccmLog" - || !artifact["sourceVersion"] - .as_str() - .is_some_and(|value| value.starts_with("5.00.TEST.")) + || artifact["sourceVersion"].as_str() != Some(EXACT_SOURCE_VERSION) { failures.push(format!( "{artifact_id} is outside the synthetic CCM/profile source boundary" @@ -763,6 +814,16 @@ fn evidence_for<'a>( reference: &Value, context: &str, ) -> Result<&'a SccmEvidence, String> { + let key = evidence_reference_key(reference, context)?; + parsed.evidence.get(&key).ok_or_else(|| { + format!( + "{context} does not cite a physical logical record: {}:{}-{}", + key.0, key.1, key.2 + ) + }) +} + +fn evidence_reference_key(reference: &Value, context: &str) -> Result<(String, u32, u32), String> { let artifact_id = required_string(reference, "artifactId", context)?; let line_start = reference["startLine"] .as_u64() @@ -772,14 +833,7 @@ fn evidence_for<'a>( .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}" - ) - }) + Ok((artifact_id.to_owned(), line_start, line_end)) } fn exact_key_fields( @@ -1064,9 +1118,22 @@ fn validate_expected( let mut terminal_deferred = false; let mut previous_utc = i64::MIN; let mut previous_phase = 0usize; + let mut seen_observation_ids = BTreeSet::new(); + let mut consumed_evidence = BTreeSet::new(); for observation in observations { - let observation_id = - required_string(observation, "observationId", transaction_id).unwrap_or("invalid"); + let observation_id = match required_string(observation, "observationId", transaction_id) + { + Ok(value) => value, + Err(error) => { + failures.push(error); + continue; + } + }; + if !seen_observation_ids.insert(observation_id) { + failures.push(format!( + "{transaction_id} contains 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"); @@ -1113,6 +1180,13 @@ fn validate_expected( &format!("{observation_id}.evidence"), &mut failures, ); + if evidence_reference_key(reference, observation_id) + .is_ok_and(|key| !consumed_evidence.insert(key)) + { + failures.push(format!( + "{transaction_id} consumes one physical evidence reference more than once" + )); + } let cited_artifact_id = required_string(reference, "artifactId", observation_id).unwrap_or("invalid"); match parsed.artifacts.get(cited_artifact_id) { @@ -1884,3 +1958,178 @@ fn source_local_classifications_are_bound_to_physical_coverage_semantics() { "source-local classifications were detached from physical coverage: {accepted:?}" ); } + +#[test] +fn path_provenance_aliases_fail_closed() { + let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut duplicate_fingerprint = healthy_manifest.clone(); + duplicate_fingerprint["artifacts"][1]["pathFingerprint"] = + duplicate_fingerprint["artifacts"][0]["pathFingerprint"].clone(); + if mutation_was_accepted("healthy-package", &duplicate_fingerprint, &healthy_expected) { + accepted.push("duplicate path fingerprint"); + } + + let mut dot_segment_alias = healthy_manifest.clone(); + dot_segment_alias["artifacts"][1]["relativePath"] = + json!("evidence/server-dp-distribution/site/current/./PkgXferMgr.log"); + if mutation_was_accepted("healthy-package", &dot_segment_alias, &healthy_expected) { + accepted.push("dot-segment physical evidence alias"); + } + + let mut unsafe_source_path = healthy_manifest.clone(); + unsafe_source_path["artifacts"][2]["sanitizedSourcePath"] = + json!("SYNTHETIC://../../Users/RealUser/SMSDPProv.log"); + if mutation_was_accepted("healthy-package", &unsafe_source_path, &healthy_expected) { + accepted.push("unsafe sanitized source path"); + } + + assert!( + accepted.is_empty(), + "unsafe or colliding path provenance was accepted: {accepted:?}" + ); +} + +#[test] +fn exact_profile_requires_the_pinned_synthetic_source_version() { + let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut missing_version = healthy_manifest.clone(); + missing_version["artifacts"][0] + .as_object_mut() + .expect("artifact is an object") + .remove("sourceVersion"); + if mutation_was_accepted("healthy-package", &missing_version, &healthy_expected) { + accepted.push("missing source version retained Exact"); + } + + for (label, version) in [ + ("unknown source version retained Exact", "5.00.TEST.UNKNOWN"), + ("malformed source version retained Exact", "5.00.TEST."), + ( + "prefix-collision source version retained Exact", + "5.00.TEST.0001-extra", + ), + ] { + let mut mutated = healthy_manifest.clone(); + mutated["artifacts"][0]["sourceVersion"] = json!(version); + if mutation_was_accepted("healthy-package", &mutated, &healthy_expected) { + accepted.push(label); + } + } + + assert!( + accepted.is_empty(), + "unvalidated source versions selected the Exact profile: {accepted:?}" + ); +} + +#[test] +fn topology_roles_are_typed_and_known() { + let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut non_string_role = healthy_manifest.clone(); + non_string_role["topology"]["rolesObserved"] + .as_array_mut() + .expect("roles are an array") + .push(json!(7)); + if mutation_was_accepted("healthy-package", &non_string_role, &healthy_expected) { + accepted.push("non-string observed role"); + } + + let mut unknown_role = healthy_manifest.clone(); + unknown_role["topology"]["rolesObserved"] + .as_array_mut() + .expect("roles are an array") + .push(json!("unknownRole")); + if mutation_was_accepted("healthy-package", &unknown_role, &healthy_expected) { + accepted.push("unknown observed role"); + } + + assert!( + accepted.is_empty(), + "malformed role topology was accepted: {accepted:?}" + ); +} + +#[test] +fn rotation_shapes_match_the_shared_canonical_contract() { + let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let healthy_expected = read_json("healthy-package", "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 current_with_value = healthy_manifest.clone(); + current_with_value["artifacts"][0]["rotation"]["value"] = json!("unexpected"); + if mutation_was_accepted("healthy-package", ¤t_with_value, &healthy_expected) { + accepted.push("current rotation with value"); + } + + let mut lo_with_value = rotation_manifest.clone(); + lo_with_value["artifacts"][1]["rotation"]["value"] = json!("unexpected"); + if mutation_was_accepted("rotation-boundary", &lo_with_value, &rotation_expected) { + accepted.push("lo_ rotation with value"); + } + + let mut numbered_zero = healthy_manifest.clone(); + numbered_zero["artifacts"][0]["rotation"]["kind"] = json!("numbered"); + numbered_zero["artifacts"][0]["rotation"]["value"] = json!(0); + if mutation_was_accepted("healthy-package", &numbered_zero, &healthy_expected) { + accepted.push("numbered rotation with zero value"); + } + + let mut malformed_timestamp = healthy_manifest.clone(); + malformed_timestamp["artifacts"][0]["rotation"]["kind"] = json!("timestamped"); + malformed_timestamp["artifacts"][0]["rotation"]["value"] = json!("20260730_122000"); + if mutation_was_accepted("healthy-package", &malformed_timestamp, &healthy_expected) { + accepted.push("timestamped rotation with noncanonical value"); + } + + assert!( + accepted.is_empty(), + "noncanonical rotation shapes were accepted: {accepted:?}" + ); +} + +#[test] +fn transaction_observation_ids_and_evidence_are_single_use() { + let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut duplicate_observation_id = healthy_expected.clone(); + duplicate_observation_id["transactions"][0]["observations"][1]["observationId"] = + json!("01-receive"); + if mutation_was_accepted( + "healthy-package", + &healthy_manifest, + &duplicate_observation_id, + ) { + accepted.push("duplicate observation ID"); + } + + let mut reused_evidence = healthy_expected.clone(); + let mut repeated = reused_evidence["transactions"][0]["observations"][5].clone(); + repeated["observationId"] = json!("07-report-copy"); + reused_evidence["transactions"][0]["observations"] + .as_array_mut() + .expect("observations are an array") + .push(repeated); + if mutation_was_accepted("healthy-package", &healthy_manifest, &reused_evidence) { + accepted.push("one physical evidence reference consumed twice"); + } + + assert!( + accepted.is_empty(), + "duplicate observations or reused evidence were accepted: {accepted:?}" + ); +} diff --git a/docs/sccm/preparation/issue-329-distribution-point-corpus.md b/docs/sccm/preparation/issue-329-distribution-point-corpus.md index aeac2a4a6..42f181215 100644 --- a/docs/sccm/preparation/issue-329-distribution-point-corpus.md +++ b/docs/sccm/preparation/issue-329-distribution-point-corpus.md @@ -82,16 +82,19 @@ packageId + extractionProfileId ``` -The synthetic profile is `dp-server-5.00.test-v1`, limited to -`5.00.TEST.*` fixture evidence. It is not a claim that a real ConfigMgr build -has been validated. +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. +Evidence later than the canonical bundle capture is rejected. Observation IDs +are unique within a transaction, and one physical +`(artifactId, startLine, endLine)` reference can be consumed only once. The outcome rules are conservative: @@ -140,9 +143,11 @@ uninstalled, unavailable, healthy, or failed. | `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, DP topology, terminal evidence, -coverage states, role provenance, causal fields, rotations, and transaction -cardinality. Each mutation must fail closed. +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 From bc457feff93ab384b5f77815510855fab1b4a61d Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:41:08 -0400 Subject: [PATCH 088/422] test(sccm): harden hierarchy corpus contracts --- .../hierarchy_and_replication/README.md | 10 +- .../target/current/despool.log | 1 + .../origin/current/sender.log | 1 + .../generic-site-token/expected.json | 39 ++ .../generic-site-token/manifest.json | 47 ++ .../target/current/rcmctrl.log | 2 + .../target/current/despool.log | 2 + .../target/current/despool.log | 2 + .../target/current/despool.log | 3 + .../target/current/despool.log | 1 + ...rarchy_and_replication_fixture_contract.rs | 544 ++++++++++++++++-- .../issue-331-hierarchy-replication-corpus.md | 20 +- 12 files changed, 625 insertions(+), 47 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/evidence/server-hierarchy-transfer/target/current/despool.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/generic-site-token/evidence/server-hierarchy-transfer/origin/current/sender.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/generic-site-token/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/generic-site-token/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-control/target/current/rcmctrl.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/target/current/despool.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/receiver-processing-failure/evidence/server-hierarchy-transfer/target/current/despool.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/evidence/server-hierarchy-transfer/target/current/despool.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/evidence/server-hierarchy-transfer/target/current/despool.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 index 7eb9d807b..7ba35ce82 100644 --- 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 @@ -6,10 +6,12 @@ physical provenance; `expected.json` records the proposed #331 evidence contract while production reducers remain dependency-blocked. Only raw CCM files from the existing hierarchy catalog are present: -`replmgr.log`, `sender.log`, `despool.log`, and `rcmctrl.log`. Every complete -record includes the semantic `SYNTHETIC FIXTURE` marker and exact synthetic -message/link/site/profile fields. Partial rotation/cap fixtures retain the -marker but intentionally do not form a logical CCM record. +`replmgr.log`, `sender.log`, `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 corpus must remain deterministic, safe to publish, and role/topology aware. Do not replace safe handles with hostnames, add database/network collection, or 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/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/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/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/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/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/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/sccm_server_hierarchy_and_replication_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_hierarchy_and_replication_fixture_contract.rs index 89a2afe02..a7690d58c 100644 --- 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 @@ -11,6 +11,7 @@ const SCENARIOS: &[&str] = &[ "absent-remote-source", "backlog-retry", "clock-offset-unknown", + "generic-site-token", "healthy-link", "incomplete", "receiver-processing-failure", @@ -207,6 +208,166 @@ fn normalized_records( 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, 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, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +struct HierarchyCandidateGroup { + key: HierarchyCandidateKey, + facts: Vec, +} + +fn hierarchy_candidate_groups( + scenario: &str, + artifacts: &[Value], +) -> Result, String> { + let mut grouped_facts = + BTreeMap::>::new(); + 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 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) { + 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) { + 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: producer_host_handle.to_owned(), + direction: direction.to_owned(), + relative_path: relative_path.to_owned(), + path_fingerprint: path_fingerprint.to_owned(), + rotation_kind: rotation_kind.to_owned(), + rotation_value: rotation_value.clone(), + rotation_lineage_id: rotation_lineage_id.to_owned(), + line_start, + line_end, + }; + 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, artifacts: &[Value]) -> Result, String> { + serde_json::to_vec(&hierarchy_candidate_groups(scenario, artifacts)?) + .map_err(|error| format!("{scenario}: candidate output serializes: {error}")) +} + fn phase_is_owned_by(basename: &str, phase: &str) -> bool { matches!( (basename, phase), @@ -339,9 +500,11 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val } 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 { @@ -361,6 +524,8 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val || !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); } } } @@ -432,6 +597,23 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val { 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) @@ -463,6 +645,51 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val { 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() @@ -486,6 +713,35 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val 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().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, &[ @@ -613,6 +869,30 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val { 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()); + } + } if transaction["confidence"] == "high" && (transaction["confidenceCeiling"] != "high" || transaction["topologyCompatibility"] != "exact" @@ -729,34 +1009,108 @@ fn hierarchy_candidates_are_deterministic_and_collision_resistant() { "{scenario}: artifact IDs are unique" ); - let canonical = artifacts - .iter() - .map(|artifact| { - ( - artifact["artifactId"].as_str(), - artifact["direction"].as_str(), - artifact["originalBasename"].as_str(), - artifact["captureState"].as_str(), - ) - }) - .collect::>(); - let reversed = artifacts - .iter() - .rev() - .map(|artifact| { - ( - artifact["artifactId"].as_str(), - artifact["direction"].as_str(), - artifact["originalBasename"].as_str(), - artifact["captureState"].as_str(), - ) - }) - .collect::>(); + let canonical = hierarchy_candidate_bytes(scenario, artifacts) + .unwrap_or_else(|error| panic!("{scenario}: {error}")); + let mut reversed_artifacts = artifacts.clone(); + reversed_artifacts.reverse(); + let reversed = hierarchy_candidate_bytes(scenario, &reversed_artifacts) + .unwrap_or_else(|error| panic!("{scenario}: {error}")); assert_eq!( canonical, reversed, - "{scenario}: input order changed candidate projection" + "{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-numbered".to_owned()); + collision["pathFingerprint"] = Value::String("synthetic:healthy-sender-numbered".to_owned()); + collision["rotation"] = serde_json::json!({ + "kind": "numbered", + "value": 1, + "lineageId": "healthy-sender-numbered", + "fragmentComplete": true + }); + artifacts.push(collision); + let groups = + hierarchy_candidate_groups("healthy-link", &artifacts).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", &artifacts).expect("candidates serialize"); + artifacts.reverse(); + let reversed = + hierarchy_candidate_bytes("healthy-link", &artifacts).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["artifacts"] + .as_array() + .expect("generic artifacts are an array"), + ) + .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] @@ -771,15 +1125,6 @@ fn hierarchy_outputs_never_promote_coverage_or_time_to_cause() { let coverage = expected["coverage"] .as_array() .unwrap_or_else(|| panic!("{scenario}: coverage is an array")); - let coverage_by_id = coverage - .iter() - .filter_map(|row| { - Some(( - row["artifactId"].as_str()?.to_owned(), - row["state"].as_str()?.to_owned(), - )) - }) - .collect::>(); for transaction in expected["transactions"] .as_array() .unwrap_or_else(|| panic!("{scenario}: transactions are an array")) @@ -801,10 +1146,22 @@ fn hierarchy_outputs_never_promote_coverage_or_time_to_cause() { let artifact_id = gap .as_str() .unwrap_or_else(|| panic!("{scenario}: gap ID is a string")); - assert_ne!( - coverage_by_id.get(artifact_id).map(String::as_str), - Some("captured"), - "{scenario}: complete capture was labeled a gap" + 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" ); } } @@ -1622,6 +1979,119 @@ fn hierarchy_schema_and_identity_mutations_fail_closed() { 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())]); diff --git a/docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md b/docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md index 22006fa1e..8294c706c 100644 --- a/docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md +++ b/docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md @@ -33,24 +33,31 @@ because its record occurred nearby in time. Origin and target direction, safe host handle, site code, source path, rotation lineage, and physical capture identity remain attached to every -artifact. 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. +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. +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. ## 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. -Bounded follow-up requests name only the relevant hierarchy source, direction, -target site, and basenames. +Every transaction gap ID must resolve to exactly one typed non-captured +manifest coverage row; missing, duplicated, malformed, or unknown rows fail +closed. Bounded follow-up requests name only the relevant hierarchy source, +direction, target site, and basenames. The proposed state sequence is: @@ -80,6 +87,7 @@ separately reviewed pair; time alone is never eligible. | `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 | From 889eaa1da73b94b6d9cc7de76465fe8997ff9626 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:51:41 -0400 Subject: [PATCH 089/422] fix(sccm): reject auxiliary broad-scope claims --- .../cmtraceopen-parser/src/sccm/findings.rs | 12 ++- .../tests/sccm_spine_contract.rs | 95 ++++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index aefb976e4..4f513a11e 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -1287,8 +1287,18 @@ fn has_passive_unbounded_confirmation_request( 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) + }); + matches!(auxiliary.text, "must" | "need" | "needs" | "should") - || tokens[auxiliary_index + 1..].iter().any(|token| { + || broad_target_follows_auxiliary + || predicate.iter().any(|token| { matches!( token.text, "archived" diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 9f9c4f986..049170c06 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -382,7 +382,7 @@ const REVIEW_UNRECOGNIZED_CONFIRMATION_REQUEST_REASONS: [&str; 10] = [ "Confirm PolicyAgent.log for fetching credentials.", ]; -const REVIEW_PASSIVE_UNBOUNDED_CONFIRMATION_REASONS: [&str; 12] = [ +const REVIEW_PASSIVE_UNBOUNDED_CONFIRMATION_REASONS: [&str; 23] = [ "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.", @@ -395,6 +395,57 @@ const REVIEW_PASSIVE_UNBOUNDED_CONFIRMATION_REASONS: [&str; 12] = [ "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.", +]; + +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] = [ @@ -2351,6 +2402,48 @@ fn finding_review_passive_unbounded_confirmation_requests_fail_at_every_public_b ); } +#[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"); From 26576748a5855c35991ba793b854803c1b69cd56 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:54:14 -0400 Subject: [PATCH 090/422] test(sccm): bind SUP profile source versions --- ..._software_update_point_fixture_contract.rs | 37 ++++++++++++++++++- .../issue-330-software-update-point-corpus.md | 6 ++- 2 files changed, 39 insertions(+), 4 deletions(-) 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 index 2c4cba027..7eaab7da3 100644 --- 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 @@ -30,6 +30,7 @@ const STATE_CHAIN: &[&str] = &[ ]; 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"; @@ -731,9 +732,9 @@ fn validate_manifest( { failures.push(format!("{artifact_id} reuses a physical path fingerprint")); } - if !prefixed_token_is_nonempty(source_version, "5.00.TEST.") { + if source_version != EXACT_SOURCE_VERSION { failures.push(format!( - "{artifact_id} is outside the synthetic version profile" + "{artifact_id} is outside the selected synthetic version profile" )); } @@ -3020,6 +3021,38 @@ fn partial_capture_malformed_bytes_and_rotation_family_fail_closed() { ); } +#[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 = diff --git a/docs/sccm/preparation/issue-330-software-update-point-corpus.md b/docs/sccm/preparation/issue-330-software-update-point-corpus.md index 3a41c8f15..76a386c24 100644 --- a/docs/sccm/preparation/issue-330-software-update-point-corpus.md +++ b/docs/sccm/preparation/issue-330-software-update-point-corpus.md @@ -82,8 +82,10 @@ syncRunId + extractionProfileId ``` -The synthetic profile is `sup-server-5.00.test-v1`, bounded to -`5.00.TEST.*` fixtures. It makes no claim about a real ConfigMgr build. +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. From 29d3bc54ae9eb68aa8cdef6588adaccaadb8de7d Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:58:01 -0400 Subject: [PATCH 091/422] fix(sccm): reject inverse auxiliary scope --- crates/cmtraceopen-parser/src/sccm/findings.rs | 4 ++++ .../tests/sccm_spine_contract.rs | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 4f513a11e..3e4fb3284 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -1295,6 +1295,9 @@ fn has_passive_unbounded_confirmation_request( 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 @@ -1312,6 +1315,7 @@ fn has_passive_unbounded_confirmation_request( | "required" ) }) + || !has_explicit_bounded_state_observation }) } diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 049170c06..0054e8fbe 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -382,7 +382,7 @@ const REVIEW_UNRECOGNIZED_CONFIRMATION_REQUEST_REASONS: [&str; 10] = [ "Confirm PolicyAgent.log for fetching credentials.", ]; -const REVIEW_PASSIVE_UNBOUNDED_CONFIRMATION_REASONS: [&str; 23] = [ +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.", @@ -406,6 +406,19 @@ const REVIEW_PASSIVE_UNBOUNDED_CONFIRMATION_REASONS: [&str; 23] = [ "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] = [ From cb2c707a26e46f3293486aca1f810722c099c553 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 02:01:32 -0400 Subject: [PATCH 092/422] test(sccm): close management provenance gaps --- ...sccm_client_management_fixture_contract.rs | 134 ++++++++++++++++-- .../issue-326-client-management-corpus.md | 24 +++- 2 files changed, 144 insertions(+), 14 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 893ecd607..425e00aba 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -300,12 +300,30 @@ fn validate_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']) - || suffix - .split('/') - .any(|component| component.is_empty() || matches!(component, "." | "..")) + || !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(['?', '#']) { @@ -316,6 +334,24 @@ fn validate_source_path( 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())) +} + fn string_array(value: &Value, context: &str) -> Result, String> { value .as_array() @@ -829,6 +865,7 @@ fn validate_contract( 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(); @@ -937,16 +974,23 @@ fn validate_contract( 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.starts_with("safe:path:326:") - || !path_fingerprints.insert(path_fingerprint.to_owned()) + 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.starts_with("5.00.TEST.") { + 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); @@ -981,10 +1025,15 @@ fn validate_contract( 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.starts_with("safe:path:326:") - || !path_fingerprints.insert(path_fingerprint.to_owned()) + 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" @@ -2700,3 +2749,72 @@ fn source_workflow_root_and_sanitized_role_path_mutation_fails_closed() { "cross-workflow evidence root and server-shaped sanitized path were accepted" ); } + +#[test] +fn version_and_physical_identity_provenance_mutations_fail_closed() { + let (script_root, script_manifest, script_expected) = load_contract("script-success"); + validate_contract( + "script-success", + &script_root, + &script_manifest, + &script_expected, + ) + .expect("the bounded synthetic source contract remains valid"); + let mut accepted = Vec::new(); + + let mut malformed_version = script_manifest.clone(); + malformed_version["artifacts"][0]["sourceVersion"] = + Value::String("5.00.TEST.UNKNOWN".to_owned()); + if mutation_was_accepted( + "script-success", + &script_root, + &malformed_version, + &script_expected, + ) { + accepted.push("malformed source version retained selected profile"); + } + + let mut leaking_fingerprint = script_manifest.clone(); + leaking_fingerprint["artifacts"][0]["pathFingerprint"] = + Value::String("safe:path:326:C:/Users/RealUser/Scripts.log".to_owned()); + if mutation_was_accepted( + "script-success", + &script_root, + &leaking_fingerprint, + &script_expected, + ) { + accepted.push("identity-bearing path fingerprint"); + } + + let mut leaking_source_path = script_manifest.clone(); + leaking_source_path["artifacts"][0]["sanitizedSourcePath"] = Value::String( + "SYNTHETIC://client/management/script-success/client-scripts/C:/Users/RealUser/current/Scripts.log" + .to_owned(), + ); + if mutation_was_accepted( + "script-success", + &script_root, + &leaking_source_path, + &script_expected, + ) { + accepted.push("identity-bearing synthetic source path"); + } + + let (mixed_root, mixed_manifest, mixed_expected) = load_contract("mixed-unrelated"); + let mut duplicate_source_identity = mixed_manifest; + duplicate_source_identity["artifacts"][4]["sanitizedSourcePath"] = + duplicate_source_identity["artifacts"][3]["sanitizedSourcePath"].clone(); + if mutation_was_accepted( + "mixed-unrelated", + &mixed_root, + &duplicate_source_identity, + &mixed_expected, + ) { + accepted.push("duplicate physical source identity under distinct fingerprints"); + } + + assert!( + accepted.is_empty(), + "version or physical provenance mutations were accepted: {accepted:?}" + ); +} diff --git a/docs/sccm/preparation/issue-326-client-management-corpus.md b/docs/sccm/preparation/issue-326-client-management-corpus.md index 99650ba7a..037afff7f 100644 --- a/docs/sccm/preparation/issue-326-client-management-corpus.md +++ b/docs/sccm/preparation/issue-326-client-management-corpus.md @@ -74,8 +74,9 @@ 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 selects the test profile, the CCM record is complete, and -its additive SCCM timestamp envelope has normalized UTC provenance. Signless +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 @@ -98,8 +99,11 @@ The preparation manifest is additive and does not reuse generic - exact client role and logical source group; - exact source/capability admission state; - canonical capture-attempt time; -- sanitized source path bound to the client role and logical source group, plus - a collision-safe path fingerprint when a candidate path was observed; +- 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; @@ -153,12 +157,14 @@ 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, and aliased cross-root path fingerprints; + 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 and unknown-version promotion; +- 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; @@ -211,6 +217,12 @@ 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: From 0a915dc679e0847b01e7aa8b608ecefd1d967d29 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 02:19:01 -0400 Subject: [PATCH 093/422] test(sccm): harden DP corpus review boundaries --- .../sccm/server/distribution_point/README.md | 6 + ...ver_distribution_point_fixture_contract.rs | 431 ++++++++++++++++-- .../issue-329-distribution-point-corpus.md | 16 +- 3 files changed, 403 insertions(+), 50 deletions(-) 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 index 088756cce..634194a3a 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/README.md +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/README.md @@ -13,6 +13,12 @@ exact DP/content/version identity from each normalized logical CCM record. - `expected.json` is a preparation label, not a frozen production API. - Exact package/content/version/DP/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. - Missing, denied, malformed, capped, or split evidence is coverage only. - Client records and timestamps alone never establish a DP transaction or cross-side cause. diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index daca035ed..da311a37b 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -33,6 +33,7 @@ const EXACT_PROFILE: &str = "dp-server-5.00.test-v1"; const EXACT_SOURCE_VERSION: &str = "5.00.TEST.0001"; const EXACT_SITE: &str = "LAB"; const EXACT_DP: &str = "safe:dp:lab-dp-01"; +const EXACT_DP_02: &str = "safe:dp:lab-dp-02"; const EXACT_SITE_SERVER: &str = "safe:server:lab-pri-01"; const EXACT_CLIENT: &str = "safe:client:lab-client-01"; @@ -55,6 +56,20 @@ fn required_string<'a>(value: &'a Value, field: &str, context: &str) -> Result<& .ok_or_else(|| format!("{context}.{field} must be a string")) } +fn required_nonempty_string<'a>( + value: &'a Value, + field: &str, + context: &str, +) -> Result<&'a str, String> { + required_string(value, field, context).and_then(|candidate| { + if candidate.is_empty() { + Err(format!("{context}.{field} must not be empty")) + } else { + Ok(candidate) + } + }) +} + fn required_array<'a>(value: &'a Value, field: &str, context: &str) -> Result<&'a [Value], String> { value[field] .as_array() @@ -260,10 +275,43 @@ fn source_path_is_bounded(relative_path: &str, basename: &str) -> bool { .is_some_and(|candidate| candidate == basename) } -fn sanitized_source_path_is_bounded(source_path: &str) -> bool { - source_path - .strip_prefix("SYNTHETIC://") - .is_some_and(segmented_path_is_safe) +fn sanitized_source_path_is_bounded(source_path: &str, basename: &str, rotation: &Value) -> bool { + let Some(suffix) = source_path.strip_prefix("SYNTHETIC://") else { + return false; + }; + if !segmented_path_is_safe(suffix) { + return false; + } + let segments = suffix.split('/').collect::>(); + if segments.len() != 3 + || !matches!( + segments[0], + "client-control" + | "default-dp-root" + | "default-site-root" + | "dp-02-root" + | "dp-root" + | "site-root" + ) + || segments[1] != "Logs" + { + return false; + } + + let expected_basename = match rotation["kind"].as_str() { + Some("current") => Some(basename.to_owned()), + Some("lo_") => basename + .strip_suffix(".log") + .map(|stem| format!("{stem}.lo_")), + Some("numbered") => rotation["value"] + .as_u64() + .map(|value| format!("{basename}.{value}")), + Some("timestamped") => rotation["value"] + .as_str() + .map(|value| format!("{basename}.{value}")), + _ => None, + }; + expected_basename.is_some_and(|expected| segments[2].eq_ignore_ascii_case(&expected)) } fn path_fingerprint_is_safe(path_fingerprint: &str) -> bool { @@ -326,16 +374,30 @@ fn validate_manifest( { failures.push("manifest topology is not the exact synthetic LAB DP".to_owned()); } - let mut distribution_point_handles = manifest["topology"]["distributionPointHandles"] - .as_array() - .map(|values| { - values - .iter() - .filter_map(Value::as_str) - .map(str::to_owned) - .collect::>() - }) - .unwrap_or_else(|| vec![EXACT_DP.to_owned()]); + let mut distribution_point_handles = Vec::new(); + match manifest["topology"].get("distributionPointHandles") { + None => distribution_point_handles.push(EXACT_DP.to_owned()), + Some(value) => match value.as_array() { + Some(values) => { + for value in values { + match value.as_str() { + Some(handle) + if !handle.is_empty() && matches!(handle, EXACT_DP | EXACT_DP_02) => + { + distribution_point_handles.push(handle.to_owned()); + } + Some(handle) => failures.push(format!( + "distributionPointHandles contains unknown handle {handle}" + )), + None => failures.push( + "distributionPointHandles entries must be nonempty strings".to_owned(), + ), + } + } + } + None => failures.push("distributionPointHandles must be an array".to_owned()), + }, + } let original_handle_order = distribution_point_handles.clone(); distribution_point_handles.sort(); distribution_point_handles.dedup(); @@ -538,19 +600,44 @@ fn validate_manifest( "{artifact_id} DP producer does not match its exact workflow subject" )); } + let rotation_kind = artifact["rotation"]["kind"].as_str(); + let rotation_lineage = + match required_nonempty_string(&artifact["rotation"], "lineageId", &context) { + Ok(value) => value.to_owned(), + Err(error) => { + failures.push(error); + String::new() + } + }; + let physical_capture = matches!(state, "captured" | "capped" | "parseFailed"); + let fragment_complete = if physical_capture { + match required_bool(&artifact["rotation"], "fragmentComplete", &context) { + Ok(value) => Some(value), + Err(error) => { + failures.push(error); + None + } + } + } else { + None + }; let path_fingerprint = artifact["pathFingerprint"].as_str(); let sanitized_source_path = artifact["sanitizedSourcePath"].as_str(); if !path_fingerprint.is_some_and(path_fingerprint_is_safe) - || !sanitized_source_path.is_some_and(sanitized_source_path_is_bounded) + || !sanitized_source_path.is_some_and(|value| { + sanitized_source_path_is_bounded(value, basename, &artifact["rotation"]) + }) { failures.push(format!("{artifact_id} leaks or omits path provenance")); } - if path_fingerprint.is_some_and(|value| !path_fingerprints.insert(value.to_owned())) { + if path_fingerprint + .map(str::to_ascii_lowercase) + .is_some_and(|value| !path_fingerprints.insert(value)) + { failures.push(format!( "{artifact_id} duplicates another physical path fingerprint" )); } - let rotation_kind = artifact["rotation"]["kind"].as_str(); let rotation_value = artifact["rotation"]["value"] .as_str() .map(str::to_owned) @@ -567,10 +654,10 @@ fn validate_manifest( ) { let physical_identity = ( producer.to_owned(), - source_path.to_owned(), - basename.to_owned(), + source_path.to_ascii_lowercase(), + basename.to_ascii_lowercase(), rotation_kind.to_owned(), - rotation_value, + rotation_value.to_ascii_lowercase(), ); if !physical_source_identities.insert(physical_identity) { failures.push(format!( @@ -608,7 +695,7 @@ fn validate_manifest( } }; - if matches!(state, "captured" | "capped" | "parseFailed") { + if physical_capture { let relative_path = match required_string(artifact, "relativePath", &context) { Ok(value) => value, Err(error) => { @@ -621,7 +708,7 @@ fn validate_manifest( "{artifact_id} has an unsafe or mismatched evidence path" )); } - if !relative_paths.insert(relative_path.to_owned()) { + if !relative_paths.insert(relative_path.to_ascii_lowercase()) { failures.push(format!( "{artifact_id} collides with another physical evidence destination" )); @@ -678,7 +765,7 @@ fn validate_manifest( encoding: artifact["encoding"].as_str().map(str::to_owned), }; let normalized = normalize_ccm_artifact(artifact_model, &content); - if artifact["rotation"]["fragmentComplete"] == false && !normalized.is_empty() { + if fragment_complete == Some(false) && !normalized.is_empty() { failures.push(format!( "{artifact_id} exposes a logical record from an incomplete rotation fragment" )); @@ -785,11 +872,8 @@ fn validate_manifest( .as_str() .unwrap_or_default() .to_owned(), - rotation_lineage: artifact["rotation"]["lineageId"] - .as_str() - .unwrap_or_default() - .to_owned(), - fragment_complete: artifact["rotation"]["fragmentComplete"].as_bool(), + rotation_lineage, + fragment_complete, }, ) .is_some() @@ -1020,6 +1104,8 @@ fn validate_expected( } let mut seen_transaction_ids = BTreeSet::new(); + let mut seen_observation_ids = BTreeSet::new(); + let mut consumed_evidence = BTreeSet::new(); for transaction in transactions { let transaction_id = match required_string(transaction, "transactionId", "transaction") { Ok(value) => value, @@ -1118,17 +1204,15 @@ fn validate_expected( let mut terminal_deferred = false; let mut previous_utc = i64::MIN; let mut previous_phase = 0usize; - let mut seen_observation_ids = BTreeSet::new(); - let mut consumed_evidence = BTreeSet::new(); for observation in observations { - let observation_id = match required_string(observation, "observationId", transaction_id) - { - Ok(value) => value, - Err(error) => { - failures.push(error); - continue; - } - }; + let observation_id = + match required_nonempty_string(observation, "observationId", transaction_id) { + Ok(value) => value, + Err(error) => { + failures.push(error); + continue; + } + }; if !seen_observation_ids.insert(observation_id) { failures.push(format!( "{transaction_id} contains duplicate observationId {observation_id}" @@ -1296,14 +1380,30 @@ fn validate_expected( )), } - let gap_ids = transaction["coverageGapArtifactIds"] - .as_array() - .map(|values| values.iter().filter_map(Value::as_str).collect::>()) - .unwrap_or_default(); + let mut gap_ids = Vec::new(); + match required_array(transaction, "coverageGapArtifactIds", transaction_id) { + Ok(values) => { + for value in values { + match value.as_str() { + Some(artifact_id) if !artifact_id.is_empty() => gap_ids.push(artifact_id), + Some(_) => failures.push(format!( + "{transaction_id} coverage gap artifact ID must not be empty" + )), + None => failures.push(format!( + "{transaction_id} coverage gap artifact IDs must be strings" + )), + } + } + } + Err(error) => failures.push(error), + } let mut sorted_gap_ids = gap_ids.clone(); sorted_gap_ids.sort_unstable(); + sorted_gap_ids.dedup(); if gap_ids != sorted_gap_ids { - failures.push(format!("{transaction_id} coverage gaps are not sorted")); + failures.push(format!( + "{transaction_id} coverage gaps must be sorted and unique" + )); } for artifact_id in gap_ids { match parsed.artifacts.get(artifact_id) { @@ -1348,9 +1448,22 @@ fn validate_expected( failures.push("source-local observations are not deterministically sorted".to_owned()); } for observation in source_local { - let observation_id = - required_string(observation, "observationId", "sourceLocalObservation") - .unwrap_or("invalid"); + let observation_id = match required_nonempty_string( + observation, + "observationId", + "sourceLocalObservation", + ) { + Ok(value) => value, + Err(error) => { + failures.push(error); + continue; + } + }; + if !seen_observation_ids.insert(observation_id) { + failures.push(format!( + "source-local observations contain duplicate observationId {observation_id}" + )); + } reject_unknown_fields( observation, &[ @@ -1424,6 +1537,13 @@ fn validate_expected( )); } } + if evidence_reference_key(reference, observation_id) + .is_ok_and(|key| !consumed_evidence.insert(key)) + { + failures.push(format!( + "{observation_id} consumes one physical evidence reference more than once" + )); + } if let Err(error) = evidence_for(parsed, reference, observation_id) { failures.push(error); } @@ -2133,3 +2253,222 @@ fn transaction_observation_ids_and_evidence_are_single_use() { "duplicate observations or reused evidence were accepted: {accepted:?}" ); } + +#[test] +fn physical_path_provenance_is_case_folded_bounded_and_basename_bound() { + let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut case_folded_fingerprint = healthy_manifest.clone(); + case_folded_fingerprint["artifacts"][1]["pathFingerprint"] = json!("synthetic:HEALTHY-DISTMGR"); + if mutation_was_accepted( + "healthy-package", + &case_folded_fingerprint, + &healthy_expected, + ) { + accepted.push("case-folded duplicate path fingerprint"); + } + + let mut basename_detached = healthy_manifest.clone(); + basename_detached["artifacts"][1]["sanitizedSourcePath"] = + basename_detached["artifacts"][0]["sanitizedSourcePath"].clone(); + if mutation_was_accepted("healthy-package", &basename_detached, &healthy_expected) { + accepted.push("sanitized source path detached from its original basename"); + } + + let mut identity_bearing_path = healthy_manifest.clone(); + identity_bearing_path["artifacts"][2]["sanitizedSourcePath"] = + json!("SYNTHETIC://Users/RealUser/SMSDPProv.log"); + if mutation_was_accepted("healthy-package", &identity_bearing_path, &healthy_expected) { + accepted.push("identity-bearing sanitized source root"); + } + + assert!( + accepted.is_empty(), + "unbounded or colliding physical path provenance was accepted: {accepted:?}" + ); +} + +#[test] +fn distribution_point_handles_are_typed_known_unique_and_complete() { + let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut non_string_handle = healthy_manifest.clone(); + non_string_handle["topology"]["distributionPointHandles"] = json!([EXACT_DP, 7]); + if mutation_was_accepted("healthy-package", &non_string_handle, &healthy_expected) { + accepted.push("non-string distribution-point topology handle"); + } + + let mut unknown_handle = healthy_manifest.clone(); + unknown_handle["topology"]["distributionPointHandles"] = json!([EXACT_DP, "safe:dp:lab-dp-99"]); + if mutation_was_accepted("healthy-package", &unknown_handle, &healthy_expected) { + accepted.push("unknown distribution-point topology handle"); + } + + let mut duplicate_handle = healthy_manifest.clone(); + duplicate_handle["topology"]["distributionPointHandles"] = json!([EXACT_DP, EXACT_DP]); + if mutation_was_accepted("healthy-package", &duplicate_handle, &healthy_expected) { + accepted.push("duplicate distribution-point topology handle"); + } + + let mut missing_primary = healthy_manifest.clone(); + missing_primary["topology"]["distributionPointHandles"] = json!(["safe:dp:lab-dp-02"]); + if mutation_was_accepted("healthy-package", &missing_primary, &healthy_expected) { + accepted.push("distribution-point topology omitted its primary handle"); + } + + assert!( + accepted.is_empty(), + "malformed distribution-point topology handles were accepted: {accepted:?}" + ); +} + +#[test] +fn physical_rotation_provenance_is_typed_and_complete() { + let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut non_boolean_fragment = healthy_manifest.clone(); + non_boolean_fragment["artifacts"][0]["rotation"]["fragmentComplete"] = json!("true"); + if mutation_was_accepted("healthy-package", &non_boolean_fragment, &healthy_expected) { + accepted.push("non-boolean physical fragment completeness"); + } + + let mut non_string_lineage = healthy_manifest.clone(); + non_string_lineage["artifacts"][0]["rotation"]["lineageId"] = json!(7); + if mutation_was_accepted("healthy-package", &non_string_lineage, &healthy_expected) { + accepted.push("non-string rotation lineage"); + } + + let mut missing_lineage = healthy_manifest.clone(); + missing_lineage["artifacts"][0]["rotation"] + .as_object_mut() + .expect("rotation is an object") + .remove("lineageId"); + if mutation_was_accepted("healthy-package", &missing_lineage, &healthy_expected) { + accepted.push("missing rotation lineage"); + } + + let mut missing_fragment = healthy_manifest.clone(); + missing_fragment["artifacts"][0]["rotation"] + .as_object_mut() + .expect("rotation is an object") + .remove("fragmentComplete"); + if mutation_was_accepted("healthy-package", &missing_fragment, &healthy_expected) { + accepted.push("missing physical fragment completeness"); + } + + assert_eq!( + rotation_from_manifest(&json!({"kind": "numbered", "value": 3})) + .expect("canonical numbered rotation"), + SccmRotation::Numbered(3) + ); + assert_eq!( + rotation_from_manifest(&json!({"kind": "timestamped", "value": "20260730-150000"})) + .expect("canonical timestamped rotation"), + SccmRotation::Timestamped("20260730-150000".to_owned()) + ); + + assert!( + accepted.is_empty(), + "malformed or incomplete rotation provenance was accepted: {accepted:?}" + ); +} + +#[test] +fn observation_ids_and_physical_evidence_are_unique_across_classes() { + let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let healthy_expected = read_json("healthy-package", "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 client_manifest = + read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); + let client_expected = + read_json("client-only-looking-request", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut empty_transaction_observation = healthy_expected.clone(); + empty_transaction_observation["transactions"][0]["observations"][0]["observationId"] = + json!(""); + if mutation_was_accepted( + "healthy-package", + &healthy_manifest, + &empty_transaction_observation, + ) { + accepted.push("empty transaction observation ID"); + } + + let mut duplicate_source_local_id = rotation_expected.clone(); + duplicate_source_local_id["sourceLocalObservations"][1]["observationId"] = + duplicate_source_local_id["sourceLocalObservations"][0]["observationId"].clone(); + if mutation_was_accepted( + "rotation-boundary", + &rotation_manifest, + &duplicate_source_local_id, + ) { + accepted.push("duplicate source-local observation ID"); + } + + let mut reused_source_local_evidence = client_expected.clone(); + let repeated = + reused_source_local_evidence["sourceLocalObservations"][0]["evidence"][0].clone(); + reused_source_local_evidence["sourceLocalObservations"][0]["evidence"] + .as_array_mut() + .expect("source-local evidence is an array") + .push(repeated); + if mutation_was_accepted( + "client-only-looking-request", + &client_manifest, + &reused_source_local_evidence, + ) { + accepted.push("source-local physical evidence consumed twice"); + } + + assert!( + accepted.is_empty(), + "observation identity or evidence single-use violations were accepted: {accepted:?}" + ); +} + +#[test] +fn coverage_gap_ids_are_typed_nonempty_unique_and_physical() { + let incomplete_manifest = read_json("incomplete", "manifest.json").expect("manifest loads"); + let incomplete_expected = read_json("incomplete", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut non_string_gap = incomplete_expected.clone(); + non_string_gap["transactions"][0]["coverageGapArtifactIds"] + .as_array_mut() + .expect("coverage gaps are an array") + .push(json!(7)); + if mutation_was_accepted("incomplete", &incomplete_manifest, &non_string_gap) { + accepted.push("non-string coverage-gap artifact ID"); + } + + let mut empty_gap = incomplete_expected.clone(); + empty_gap["transactions"][0]["coverageGapArtifactIds"][0] = json!(""); + if mutation_was_accepted("incomplete", &incomplete_manifest, &empty_gap) { + accepted.push("empty coverage-gap artifact ID"); + } + + let mut duplicate_gap = incomplete_expected.clone(); + let repeated = duplicate_gap["transactions"][0]["coverageGapArtifactIds"][1].clone(); + duplicate_gap["transactions"][0]["coverageGapArtifactIds"] + .as_array_mut() + .expect("coverage gaps are an array") + .push(repeated); + if mutation_was_accepted("incomplete", &incomplete_manifest, &duplicate_gap) { + accepted.push("duplicate coverage-gap artifact ID"); + } + + assert!( + accepted.is_empty(), + "malformed coverage-gap artifact IDs were accepted: {accepted:?}" + ); +} diff --git a/docs/sccm/preparation/issue-329-distribution-point-corpus.md b/docs/sccm/preparation/issue-329-distribution-point-corpus.md index 42f181215..d398e27c6 100644 --- a/docs/sccm/preparation/issue-329-distribution-point-corpus.md +++ b/docs/sccm/preparation/issue-329-distribution-point-corpus.md @@ -49,8 +49,11 @@ Each manifest preserves: 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; -- rotation kind, lineage, and fragment completeness; + 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. @@ -61,6 +64,8 @@ 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. +An explicit multi-DP handle array is parsed element by element; malformed, +unknown, duplicate, or primary-omitting entries cannot be projected away. ## State and exact-key contract @@ -93,8 +98,9 @@ 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 unique within a transaction, and one physical -`(artifactId, startLine, endLine)` reference can be consumed only once. +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: @@ -113,6 +119,8 @@ The outcome rules are conservative: `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: From 6e19626bb1b0a6ff9aaa7336d5b61b24d87f1824 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 02:21:43 -0400 Subject: [PATCH 094/422] test(sccm): bind hierarchy artifact requests --- .../hierarchy_and_replication/README.md | 12 +- ...rarchy_and_replication_fixture_contract.rs | 393 +++++++++++++----- 2 files changed, 290 insertions(+), 115 deletions(-) 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 index 7ba35ce82..63f6e42f6 100644 --- 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 @@ -6,12 +6,12 @@ physical provenance; `expected.json` records the proposed #331 evidence contract while production reducers remain dependency-blocked. Only raw CCM files from the existing hierarchy catalog are present: -`replmgr.log`, `sender.log`, `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. +`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 corpus must remain deterministic, safe to publish, and role/topology aware. Do not replace safe handles with hostnames, add database/network collection, or 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 index a7690d58c..7fb5bfe5c 100644 --- 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 @@ -455,6 +455,217 @@ fn declared_target_site_codes(manifest: &Value) -> BTreeSet<&str> { .collect() } +#[derive(Debug, Eq, PartialEq)] +struct ArtifactRequestBasis { + source_id: String, + direction: String, + target_site_code: String, + basenames: Vec, +} + +fn request_direction_matches(request_direction: &str, artifact_direction: &str) -> bool { + request_direction == artifact_direction + || (request_direction == "both" && matches!(artifact_direction, "origin" | "target")) +} + +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 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 artifacts = manifest["artifacts"].as_array(); + let target_sites = declared_target_site_codes(manifest); + let request_keys = requests + .iter() + .map(|request| { + ( + request["sourceId"].as_str(), + request["direction"].as_str(), + request["targetSiteCode"].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 = match source_id { + Some("server-hierarchy-control") => basenames + .iter() + .all(|basename| matches!(*basename, "replmgr.log")), + Some("server-hierarchy-transfer") => basenames + .iter() + .all(|basename| matches!(*basename, "sender.log" | "sender.lo_" | "despool.log")), + _ => false, + }; + 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")) => { + let expected_state = match reason { + "coverageAbsent" => Some("absent"), + "coverageCapped" => Some("capped"), + "coverageRotationSplit" => None, + _ => unreachable!(), + }; + let matching = artifacts + .into_iter() + .flatten() + .filter(|artifact| { + artifact["sourceId"].as_str() == source_id + && artifact["direction"] + .as_str() + .is_some_and(|artifact_direction| { + request_direction_matches( + direction.unwrap_or_default(), + artifact_direction, + ) + }) + && expected_state.map_or_else( + || artifact["rotation"]["fragmentComplete"] == false, + |state| artifact["captureState"] == state, + ) + }) + .collect::>(); + let matching_basenames = matching + .iter() + .filter_map(|artifact| artifact["originalBasename"].as_str()) + .collect::>() + .into_iter() + .collect::>(); + !matching.is_empty() + && (reason != "coverageRotationSplit" || matching.len() >= 2) + && matching_basenames == basenames + } + _ => false, + }; + if !backed { + failures.push(format!( + "{scenario}: request is not backed by exact coverage/time evidence" + )); + } + } + + failures +} + fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec { let mut failures = Vec::new(); if !object_has_only( @@ -957,6 +1168,8 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val failures.push("source-local identity/cardinality matrix changed".to_owned()); } + failures.extend(artifact_request_failures(scenario, manifest, expected)); + failures } @@ -1661,113 +1874,10 @@ fn hierarchy_gaps_requests_and_source_local_controls_are_bounded() { 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("artifacts are an array") - .iter() - .filter_map(|artifact| { - Some(( - artifact["artifactId"].as_str()?.to_owned(), - artifact.clone(), - )) - }) - .collect::>(); - + failures.extend(artifact_request_failures(scenario, &manifest, &expected)); let requests = expected["artifactRequests"] .as_array() .expect("artifact requests are an array"); - let request_keys = requests - .iter() - .map(|request| { - ( - request["sourceId"].as_str(), - request["direction"].as_str(), - request["targetSiteCode"].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 { - 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 basenames = request["basenames"] - .as_array() - .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(); - if !matches!( - source_id, - Some("server-hierarchy-control" | "server-hierarchy-transfer") - ) || request["producerRole"] != "siteServer" - || !matches!(direction, Some("origin" | "target" | "both")) - || target_site.is_none_or(|site| { - site.len() != 3 - || !site - .bytes() - .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) - }) - || !matches!( - reason, - Some( - "coverageAbsent" - | "coverageCapped" - | "coverageRotationSplit" - | "invalidOffset" - ) - ) - || basenames != sorted_basenames - || basenames.iter().any(|basename| { - !matches!( - *basename, - "replmgr.log" | "sender.log" | "sender.lo_" | "despool.log" - ) - }) - { - failures.push(format!("{scenario}: request is broad or malformed")); - } - let backed = match reason { - Some("coverageAbsent") => artifacts.values().any(|artifact| { - artifact["sourceId"] == request["sourceId"] - && artifact["direction"] == request["direction"] - && artifact["captureState"] == "absent" - }), - Some("coverageCapped") => artifacts.values().any(|artifact| { - artifact["sourceId"] == request["sourceId"] - && artifact["direction"] == request["direction"] - && artifact["captureState"] == "capped" - }), - Some("coverageRotationSplit") => { - artifacts - .values() - .filter(|artifact| { - artifact["sourceId"] == request["sourceId"] - && artifact["direction"] == request["direction"] - && artifact["rotation"]["fragmentComplete"] == false - }) - .count() - >= 2 - } - Some("invalidOffset") => records.values().any(|record| { - record.timestamp.ordering_state == SccmTimeOrderingState::OffsetInvalid - }), - _ => false, - }; - if !backed { - failures.push(format!( - "{scenario}: request is not backed by exact coverage/time evidence" - )); - } - } let request_reason_codes = requests .iter() @@ -1834,15 +1944,19 @@ fn hierarchy_gaps_requests_and_source_local_controls_are_bounded() { } let contract = - include_str!("../../../docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md"); + 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\nalone cannot create a transaction", - "A missing remote artifact\nis a coverage state, not evidence that the remote role is absent or broken", + "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\nacceptance source", + "not an acceptance source", ] { - if !contract.contains(required) { + let required = required.split_whitespace().collect::>().join(" "); + if !contract.contains(&required) { failures.push(format!("preparation document lost boundary: {required}")); } } @@ -2104,3 +2218,64 @@ fn hierarchy_schema_and_identity_mutations_fail_closed() { "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 invalid-offset request is the bounded control" + ); + assert!( + artifact_request_failures("clock-offset-unknown", &manifest, &expected).is_empty(), + "the shared request loader accepts the bounded both-direction control" + ); + + let mutations = [ + ( + "wrong source ID", + "sourceId", + serde_json::json!("server-hierarchy-control"), + ), + ("wrong direction", "direction", serde_json::json!("origin")), + ( + "undeclared target site", + "targetSiteCode", + serde_json::json!("XYZ"), + ), + ( + "wrong source basename", + "basenames", + serde_json::json!(["replmgr.log"]), + ), + ( + "missing origin companion", + "basenames", + serde_json::json!(["despool.log"]), + ), + ( + "missing target companion", + "basenames", + serde_json::json!(["sender.log"]), + ), + ]; + + 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:?}" + ); +} From 79e6c5a33450a3a9b6fb006d9726ac95496544e2 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 02:21:52 -0400 Subject: [PATCH 095/422] test(sccm): require explicit DP topology arrays --- .../absent-dp/manifest.json | 2 +- .../client-only-looking-request/manifest.json | 2 +- .../distribution-failure/manifest.json | 2 +- .../healthy-package/manifest.json | 1 + .../incomplete/manifest.json | 2 +- .../rotation-boundary/manifest.json | 2 +- .../serve-observed/manifest.json | 2 +- .../transfer-retry/manifest.json | 2 +- .../validation-failure/manifest.json | 2 +- ...ver_distribution_point_fixture_contract.rs | 48 +++++++++++-------- .../issue-329-distribution-point-corpus.md | 5 +- 11 files changed, 41 insertions(+), 29 deletions(-) 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 index e4d1cc788..f4ab8b390 100644 --- 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 @@ -3,7 +3,7 @@ "proposalOnly": true, "syntheticFixture": true, "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, - "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "rolesObserved": ["distributionPoint", "siteServer"]}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint", "siteServer"]}, "artifacts": [ { "artifactId": "dp-absent-01-distmgr", diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/manifest.json index 33055c5f4..fab490129 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/manifest.json @@ -3,7 +3,7 @@ "proposalOnly": true, "syntheticFixture": true, "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, - "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "rolesObserved": ["siteServer"]}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["siteServer"]}, "artifacts": [ { "artifactId": "dp-client-control-01-data-transfer", 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 index 5ad7144f4..779a87c46 100644 --- 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 @@ -3,7 +3,7 @@ "proposalOnly": true, "syntheticFixture": true, "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, - "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "rolesObserved": ["distributionPoint", "siteServer"]}, + "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", 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 index 8f6a931e5..03efabfb5 100644 --- 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 @@ -10,6 +10,7 @@ "topology": { "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", + "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint", "siteServer"] }, "artifacts": [ 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 index ae905b3aa..47872848c 100644 --- 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 @@ -3,7 +3,7 @@ "proposalOnly": true, "syntheticFixture": true, "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, - "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "rolesObserved": ["distributionPoint", "siteServer"]}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint", "siteServer"]}, "artifacts": [ { "artifactId": "dp-incomplete-01-distmgr", 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 index 844006abb..a4ad922e6 100644 --- 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 @@ -3,7 +3,7 @@ "proposalOnly": true, "syntheticFixture": true, "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, - "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "rolesObserved": ["distributionPoint", "siteServer"]}, + "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", 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 index 494aea4a1..3edd339dd 100644 --- 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 @@ -3,7 +3,7 @@ "proposalOnly": true, "syntheticFixture": true, "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, - "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "rolesObserved": ["distributionPoint", "siteServer"]}, + "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "distributionPointHandles": ["safe:dp:lab-dp-01"], "rolesObserved": ["distributionPoint", "siteServer"]}, "artifacts": [ { "artifactId": "dp-serve-01-distmgr", 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 index f7773f185..923f68992 100644 --- 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 @@ -3,7 +3,7 @@ "proposalOnly": true, "syntheticFixture": true, "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, - "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "rolesObserved": ["distributionPoint", "siteServer"]}, + "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", 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 index a69dd593e..4be8dc67c 100644 --- 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 @@ -3,7 +3,7 @@ "proposalOnly": true, "syntheticFixture": true, "bundle": {"bundleRole": "server", "workflow": "distributionPoint", "capturedUtc": "2026-07-30T12:30:00Z"}, - "topology": {"siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "rolesObserved": ["distributionPoint", "siteServer"]}, + "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", diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index da311a37b..9cbfc1181 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -375,28 +375,29 @@ fn validate_manifest( failures.push("manifest topology is not the exact synthetic LAB DP".to_owned()); } let mut distribution_point_handles = Vec::new(); - match manifest["topology"].get("distributionPointHandles") { - None => distribution_point_handles.push(EXACT_DP.to_owned()), - Some(value) => match value.as_array() { - Some(values) => { - for value in values { - match value.as_str() { - Some(handle) - if !handle.is_empty() && matches!(handle, EXACT_DP | EXACT_DP_02) => - { - distribution_point_handles.push(handle.to_owned()); - } - Some(handle) => failures.push(format!( - "distributionPointHandles contains unknown handle {handle}" - )), - None => failures.push( - "distributionPointHandles entries must be nonempty strings".to_owned(), - ), + match required_array( + &manifest["topology"], + "distributionPointHandles", + "topology", + ) { + Ok(values) => { + for value in values { + match value.as_str() { + Some(handle) + if !handle.is_empty() && matches!(handle, EXACT_DP | EXACT_DP_02) => + { + distribution_point_handles.push(handle.to_owned()); } + Some(handle) => failures.push(format!( + "distributionPointHandles contains unknown handle {handle}" + )), + None => failures.push( + "distributionPointHandles entries must be nonempty strings".to_owned(), + ), } } - None => failures.push("distributionPointHandles must be an array".to_owned()), - }, + } + Err(error) => failures.push(error), } let original_handle_order = distribution_point_handles.clone(); distribution_point_handles.sort(); @@ -2320,6 +2321,15 @@ fn distribution_point_handles_are_typed_known_unique_and_complete() { accepted.push("distribution-point topology omitted its primary handle"); } + let mut missing_handle_array = healthy_manifest.clone(); + missing_handle_array["topology"] + .as_object_mut() + .expect("topology is an object") + .remove("distributionPointHandles"); + if mutation_was_accepted("healthy-package", &missing_handle_array, &healthy_expected) { + accepted.push("distribution-point topology omitted its exact handle array"); + } + assert!( accepted.is_empty(), "malformed distribution-point topology handles were accepted: {accepted:?}" diff --git a/docs/sccm/preparation/issue-329-distribution-point-corpus.md b/docs/sccm/preparation/issue-329-distribution-point-corpus.md index d398e27c6..5ed8256aa 100644 --- a/docs/sccm/preparation/issue-329-distribution-point-corpus.md +++ b/docs/sccm/preparation/issue-329-distribution-point-corpus.md @@ -64,8 +64,9 @@ 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. -An explicit multi-DP handle array is parsed element by element; malformed, -unknown, duplicate, or primary-omitting entries cannot be projected away. +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 From 8a07abf86864788595a54608e01f407da0e99c63 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 02:28:27 -0400 Subject: [PATCH 096/422] test(sccm): harden provider corpus contracts --- ...ider_and_admin_service_fixture_contract.rs | 312 ++++++++++++++++-- 1 file changed, 278 insertions(+), 34 deletions(-) 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 index 0f81c1d96..5bf71d4e3 100644 --- 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 @@ -87,6 +87,71 @@ fn safe_segmented_path(value: &str, prefix: &str) -> bool { }) } +fn safe_opaque_token(value: &str, prefix: &str, max_suffix_len: usize) -> bool { + value.strip_prefix(prefix).is_some_and(|suffix| { + !suffix.is_empty() + && suffix.len() <= max_suffix_len + && suffix + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }) +} + +fn expected_sanitized_source_path( + source_id: Option<&str>, + rotation: &Value, +) -> Option<&'static str> { + match (source_id, rotation["kind"].as_str()) { + (Some("server-provider"), Some("current")) => { + Some("SYNTHETIC://configured-root/LAB/Logs/Smsprov.log") + } + (Some("server-provider"), Some("lo_")) => { + Some("SYNTHETIC://configured-root/LAB/Logs/Smsprov.lo_") + } + (Some("server-admin-service"), Some("current")) => { + Some("SYNTHETIC://configured-root/LAB/Logs/AdminService.log") + } + (Some("server-admin-service-iis"), Some("current")) => { + Some("SYNTHETIC://scoped-export/LAB/IIS/u_ex_synthetic.log") + } + _ => None, + } +} + +fn public_projection_contains_sensitive_shape(value: &Value) -> bool { + match value { + Value::String(value) => { + let value = value.to_ascii_lowercase(); + [ + "@", + "bearer", + "select ", + "http://", + "https://", + "/adminservice", + ] + .iter() + .any(|term| value.contains(term)) + } + Value::Array(values) => values + .iter() + .any(public_projection_contains_sensitive_shape), + Value::Object(values) => values + .values() + .any(public_projection_contains_sensitive_shape), + _ => false, + } +} + +fn physical_line_count(scenario: &str, relative_path: &str) -> Option { + if !safe_segmented_path(relative_path, "evidence/") { + return None; + } + fs::read_to_string(corpus_root().join(scenario).join(relative_path)) + .ok() + .map(|content| content.lines().count() as u64) +} + fn coverage_state(value: &str) -> Option { match value { "captured" => Some(SccmCoverageState::Captured), @@ -396,9 +461,9 @@ fn expected_profile_layers(scenario: &str) -> &'static [&'static str] { } fn known_test_version(value: &str) -> bool { - value.strip_prefix("5.00.TEST.").is_some_and(|suffix| { - !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit()) - }) + value + .strip_prefix("5.00.TEST.") + .is_some_and(|suffix| suffix.len() == 4 && suffix.bytes().all(|byte| byte.is_ascii_digit())) } fn safe_version(value: &str) -> bool { @@ -474,6 +539,7 @@ fn schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec Vec Vec Vec Vec Vec { @@ -624,11 +702,9 @@ fn schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec { - if artifact["relativePath"] - .as_str() - .is_none_or(|value| !safe_segmented_path(value, "evidence/")) - || artifact["relativePath"] - .as_str() + let relative_path = artifact["relativePath"].as_str(); + if relative_path.is_none_or(|value| !safe_segmented_path(value, "evidence/")) + || relative_path .map(str::to_ascii_lowercase) .is_none_or(|value| !destinations.insert(value)) || artifact["bytesCopied"].as_u64().is_none() @@ -644,6 +720,11 @@ fn schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec { if artifact.get("relativePath").is_some() @@ -681,6 +762,7 @@ fn schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec>() { failures.push("artifact IDs are not exact sorted unique strings".to_owned()); } @@ -705,6 +787,9 @@ fn schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec Vec 0 + && end >= start + && physical_line_counts + .get(artifact_id) + .is_some_and(|line_count| end <= *line_count) + }); if !object_has_only(reference, &["artifactId", "startLine", "endLine"]) - || reference["artifactId"] - .as_str() - .is_none_or(|id| !artifact_sources.contains_key(id)) - || reference["artifactId"].as_str().is_some_and(|id| { + || artifact_id.is_none_or(|id| !artifact_sources.contains_key(id)) + || artifact_id.is_some_and(|id| { let observation_layer = observation["layer"].as_str().unwrap_or_default(); artifact_sources .get(id) .is_none_or(|(_, artifact_layer)| artifact_layer != observation_layer) }) - || reference["startLine"].as_u64().is_none_or(|line| line == 0) - || reference["endLine"].as_u64().is_none_or(|line| line == 0) + || !physical_range_is_valid { failures.push("source-local evidence reference is malformed".to_owned()); } - if let (Some(artifact_id), Some(start), Some(end)) = ( - reference["artifactId"].as_str(), - reference["startLine"].as_u64(), - reference["endLine"].as_u64(), - ) { + if let (Some(artifact_id), Some(start), Some(end)) = (artifact_id, start, end) { if !cited_references.insert((artifact_id, start, end)) { failures.push("source-local evidence reference is duplicated".to_owned()); } @@ -1633,3 +1725,155 @@ fn schema_and_identity_mutations_fail_closed() { assert!(accepted.is_empty(), "accepted mutations: {accepted:#?}"); } + +#[test] +fn review_privacy_topology_profile_and_citation_mutations_fail_closed() { + let manifest = read_json("provider-success", "manifest.json").unwrap(); + let expected = read_json("provider-success", "expected.json").unwrap(); + let privacy_manifest = read_json("privacy-redaction", "manifest.json").unwrap(); + let privacy_expected = read_json("privacy-redaction", "expected.json").unwrap(); + let iis_manifest = read_json("iis-supplemental", "manifest.json").unwrap(); + let iis_expected = read_json("iis-supplemental", "expected.json").unwrap(); + let timeout_manifest = read_json("provider-timeout", "manifest.json").unwrap(); + let timeout_expected = read_json("provider-timeout", "expected.json").unwrap(); + let mut accepted = Vec::new(); + + let mut noncanonical_known_version = manifest.clone(); + noncanonical_known_version["artifacts"][0]["sourceVersion"] = + Value::String("5.00.TEST.1".to_owned()); + if schema_failures("provider-success", &noncanonical_known_version, &expected).is_empty() { + accepted.push("noncanonical synthetic source version selected an exact profile"); + } + + let mut overlong_known_version = manifest.clone(); + overlong_known_version["artifacts"][0]["sourceVersion"] = + Value::String("5.00.TEST.00010".to_owned()); + if schema_failures("provider-success", &overlong_known_version, &expected).is_empty() { + accepted.push("overlong synthetic source version selected an exact profile"); + } + + let mut topology_host_mismatch = manifest.clone(); + topology_host_mismatch["artifacts"][0]["producerHostHandle"] = + Value::String("safe:server:different-host".to_owned()); + if schema_failures("provider-success", &topology_host_mismatch, &expected).is_empty() { + accepted.push("artifact producer host diverged from its endpoint host"); + } + + let mut identity_bearing_host = manifest.clone(); + identity_bearing_host["topology"]["endpoints"][0]["hostHandle"] = + Value::String("safe:server:synthetic.user@example.invalid".to_owned()); + identity_bearing_host["artifacts"][0]["producerHostHandle"] = + Value::String("safe:server:synthetic.user@example.invalid".to_owned()); + if schema_failures("provider-success", &identity_bearing_host, &expected).is_empty() { + accepted.push("identity-bearing host provenance"); + } + + let mut identity_bearing_source_path = manifest.clone(); + identity_bearing_source_path["artifacts"][0]["sanitizedSourcePath"] = + Value::String("SYNTHETIC://Users/Adam.Gell/LAB/Logs/Smsprov.log".to_owned()); + if schema_failures("provider-success", &identity_bearing_source_path, &expected).is_empty() { + accepted.push("identity-bearing sanitized source path"); + } + + let mut identity_bearing_fingerprint = manifest.clone(); + identity_bearing_fingerprint["artifacts"][0]["pathFingerprint"] = + Value::String("synthetic:synthetic.user@example.invalid".to_owned()); + if schema_failures("provider-success", &identity_bearing_fingerprint, &expected).is_empty() { + accepted.push("identity-bearing path fingerprint"); + } + + let mut basename_mismatch = manifest.clone(); + basename_mismatch["artifacts"][0]["sanitizedSourcePath"] = + Value::String("SYNTHETIC://configured-root/LAB/Logs/AdminService.log".to_owned()); + if schema_failures("provider-success", &basename_mismatch, &expected).is_empty() { + accepted.push("sanitized source path basename diverged from source identity"); + } + + let mut duplicate_sanitized_physical_identity = privacy_manifest.clone(); + duplicate_sanitized_physical_identity["artifacts"][1]["sanitizedSourcePath"] = + duplicate_sanitized_physical_identity["artifacts"][0]["sanitizedSourcePath"].clone(); + if schema_failures( + "privacy-redaction", + &duplicate_sanitized_physical_identity, + &privacy_expected, + ) + .is_empty() + { + accepted.push("duplicate sanitized physical identity hidden by distinct fingerprints"); + } + + let mut out_of_range_source_local_citation = iis_expected.clone(); + out_of_range_source_local_citation["sourceLocalObservations"][0]["evidence"][0]["startLine"] = + Value::from(9999_u64); + out_of_range_source_local_citation["sourceLocalObservations"][0]["evidence"][0]["endLine"] = + Value::from(9999_u64); + if schema_failures( + "iis-supplemental", + &iis_manifest, + &out_of_range_source_local_citation, + ) + .is_empty() + { + accepted.push("source-local citation points outside physical evidence"); + } + + let mut reversed_source_local_citation = iis_expected.clone(); + reversed_source_local_citation["sourceLocalObservations"][0]["evidence"][0]["startLine"] = + Value::from(2_u64); + reversed_source_local_citation["sourceLocalObservations"][0]["evidence"][0]["endLine"] = + Value::from(1_u64); + if schema_failures( + "iis-supplemental", + &iis_manifest, + &reversed_source_local_citation, + ) + .is_empty() + { + accepted.push("source-local citation has a reversed line range"); + } + + let mut private_source_local_reason = privacy_expected.clone(); + private_source_local_reason["sourceLocalObservations"][0]["reason"] = + Value::String("Caller synthetic.user@example.invalid used a raw token".to_owned()); + if schema_failures( + "privacy-redaction", + &privacy_manifest, + &private_source_local_reason, + ) + .is_empty() + { + accepted.push("source-local public reason leaks caller identity"); + } + + let mut private_request_reason = timeout_expected.clone(); + private_request_reason["artifactRequests"][0]["reason"] = + Value::String("Capture caller synthetic.user@example.invalid bearer token".to_owned()); + if schema_failures( + "provider-timeout", + &timeout_manifest, + &private_request_reason, + ) + .is_empty() + { + accepted.push("artifact request public reason leaks caller and token material"); + } + + let mut extra_unobserved_endpoint = manifest.clone(); + extra_unobserved_endpoint["topology"]["endpoints"] + .as_array_mut() + .unwrap() + .insert( + 0, + serde_json::json!({ + "endpointId": "aaa-unobserved", + "layer": "provider", + "hostHandle": "safe:server:unused", + "producerRole": "provider" + }), + ); + if schema_failures("provider-success", &extra_unobserved_endpoint, &expected).is_empty() { + accepted.push("unobserved extra endpoint entered exact topology"); + } + + assert!(accepted.is_empty(), "accepted mutations: {accepted:#?}"); +} From b22974170517ec62761a68868f42c34312f612a1 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 02:30:04 -0400 Subject: [PATCH 097/422] test(sccm): reject malformed source-local artifact ids --- ...ver_distribution_point_fixture_contract.rs | 98 ++++++++++++++++++- .../issue-329-distribution-point-corpus.md | 4 +- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index 9cbfc1181..0c16214cc 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -1492,10 +1492,25 @@ fn validate_expected( "{observation_id} is not an explicitly noncorrelatable source-local observation" )); } - let artifact_ids = observation["artifactIds"] - .as_array() - .map(|values| values.iter().filter_map(Value::as_str).collect::>()) - .unwrap_or_default(); + let mut artifact_ids = Vec::new(); + match required_array(observation, "artifactIds", observation_id) { + Ok(values) => { + for value in values { + match value.as_str() { + Some(artifact_id) if !artifact_id.is_empty() => { + artifact_ids.push(artifact_id); + } + Some(_) => { + failures.push(format!("{observation_id} artifact ID must not be empty")) + } + None => { + failures.push(format!("{observation_id} artifact IDs must be strings")) + } + } + } + } + Err(error) => failures.push(error), + } let mut sorted_artifact_ids = artifact_ids.clone(); sorted_artifact_ids.sort_unstable(); let unique_artifact_ids = artifact_ids.iter().copied().collect::>(); @@ -2446,6 +2461,81 @@ fn observation_ids_and_physical_evidence_are_unique_across_classes() { ); } +#[test] +fn source_local_artifact_ids_are_strict_strings_across_classifications() { + let client_manifest = + read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); + let client_expected = + read_json("client-only-looking-request", "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 surfaces = [ + ( + "ignoredClientEvidence", + "client-only-looking-request", + &client_manifest, + &client_expected, + 0usize, + ), + ( + "rotationSplit", + "rotation-boundary", + &rotation_manifest, + &rotation_expected, + 0usize, + ), + ( + "malformedEvidence", + "rotation-boundary", + &rotation_manifest, + &rotation_expected, + 1usize, + ), + ]; + let invalid_entries = [ + ("numeric", json!(7)), + ("null", Value::Null), + ("boolean", json!(true)), + ("object", json!({"unexpected": "value"})), + ("empty string", json!("")), + ]; + let mut accepted = Vec::new(); + + for (surface, scenario, manifest, expected, observation_index) in surfaces { + for (shape, invalid_entry) in &invalid_entries { + let mut mutated = expected.clone(); + mutated["sourceLocalObservations"][observation_index]["artifactIds"] + .as_array_mut() + .expect("source-local artifact IDs are an array") + .push(invalid_entry.clone()); + if mutation_was_accepted(scenario, manifest, &mutated) { + accepted.push(format!("{surface} accepted appended {shape} artifact ID")); + } + } + + let mut mixed_array = expected.clone(); + mixed_array["sourceLocalObservations"][observation_index]["artifactIds"] + .as_array_mut() + .expect("source-local artifact IDs are an array") + .extend([ + json!(7), + Value::Null, + json!(true), + json!({"unexpected": "value"}), + ]); + if mutation_was_accepted(scenario, manifest, &mixed_array) { + accepted.push(format!("{surface} accepted a mixed-type artifact ID array")); + } + } + + assert!( + accepted.is_empty(), + "malformed source-local physical artifact IDs were accepted: {accepted:?}" + ); +} + #[test] fn coverage_gap_ids_are_typed_nonempty_unique_and_physical() { let incomplete_manifest = read_json("incomplete", "manifest.json").expect("manifest loads"); diff --git a/docs/sccm/preparation/issue-329-distribution-point-corpus.md b/docs/sccm/preparation/issue-329-distribution-point-corpus.md index 5ed8256aa..42169ec4a 100644 --- a/docs/sccm/preparation/issue-329-distribution-point-corpus.md +++ b/docs/sccm/preparation/issue-329-distribution-point-corpus.md @@ -112,7 +112,9 @@ The outcome rules are conservative: 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; and + 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 - a client-only download record cannot become a DP transaction or DP failure. ## Coverage and request contract From dfe32e94a79b55decb2be21dc92dcb79bc52206e Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 02:51:53 -0400 Subject: [PATCH 098/422] test(sccm): bind source-local evidence to physical lines --- .../sccm/server/distribution_point/README.md | 3 + .../rotation-boundary/expected.json | 4 +- ...ver_distribution_point_fixture_contract.rs | 202 +++++++++++++++++- .../issue-329-distribution-point-corpus.md | 5 +- 4 files changed, 200 insertions(+), 14 deletions(-) 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 index 634194a3a..f6a9d16e3 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/README.md +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/README.md @@ -19,6 +19,9 @@ exact DP/content/version identity from each normalized logical CCM record. - 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. 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 index 1afd42488..0c081832e 100644 --- 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 @@ -13,8 +13,8 @@ ], "transactions": [], "sourceLocalObservations": [ - {"observationId": "rotation-01-split", "classification": "rotationSplit", "confidence": "low", "confidenceCeiling": "low", "correlationEligible": false, "artifactIds": ["dp-rotation-01-current-fragment", "dp-rotation-02-lo-fragment"], "evidence": []}, - {"observationId": "rotation-02-malformed", "classification": "malformedEvidence", "confidence": "low", "confidenceCeiling": "low", "correlationEligible": false, "artifactIds": ["dp-rotation-03-malformed"], "evidence": []} + {"observationId": "rotation-01-split", "classification": "rotationSplit", "confidence": "low", "confidenceCeiling": "low", "correlationEligible": false, "artifactIds": ["dp-rotation-01-current-fragment", "dp-rotation-02-lo-fragment"], "evidence": [{"artifactId": "dp-rotation-01-current-fragment", "startLine": 1, "endLine": 1}, {"artifactId": "dp-rotation-02-lo-fragment", "startLine": 1, "endLine": 1}]}, + {"observationId": "rotation-02-malformed", "classification": "malformedEvidence", "confidence": "low", "confidenceCeiling": "low", "correlationEligible": false, "artifactIds": ["dp-rotation-03-malformed"], "evidence": [{"artifactId": "dp-rotation-03-malformed", "startLine": 1, "endLine": 1}]} ], "artifactRequests": [ {"sourceId": "server-dp-distribution", "reasonCode": "coverageMalformed"}, diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index 0c16214cc..f8fc009b7 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -204,6 +204,7 @@ struct ParsedArtifact { struct ParsedScenario { artifacts: BTreeMap, evidence: BTreeMap<(String, u32, u32), SccmEvidence>, + physical_evidence: BTreeSet<(String, u32, u32)>, distribution_point_handles: BTreeSet, } @@ -479,6 +480,7 @@ fn validate_manifest( let mut parsed_artifacts = BTreeMap::new(); let mut evidence_by_reference = BTreeMap::new(); + let mut physical_evidence_by_reference = BTreeSet::new(); let mut relative_paths = BTreeSet::new(); let mut physical_source_identities = BTreeSet::new(); let mut path_fingerprints = BTreeSet::new(); @@ -744,11 +746,24 @@ fn validate_manifest( "{artifact_id} has incoherent raw-byte collection-limit provenance" )); } - if !String::from_utf8_lossy(&bytes).contains("SYNTHETIC FIXTURE") { + let content = String::from_utf8_lossy(&bytes); + if !content.contains("SYNTHETIC FIXTURE") { failures.push(format!("{artifact_id} lacks a synthetic fixture marker")); } + for (line_index, _) in content.lines().enumerate() { + let Ok(line_number) = u32::try_from(line_index + 1) else { + failures.push(format!( + "{artifact_id} has more physical lines than an evidence reference can address" + )); + break; + }; + physical_evidence_by_reference.insert(( + artifact_id.to_owned(), + line_number, + line_number, + )); + } if matches!(state, "captured" | "capped") { - let content = String::from_utf8_lossy(&bytes); let artifact_model = SccmArtifact { artifact_id: artifact_id.to_owned(), display_name: basename.to_owned(), @@ -887,6 +902,7 @@ fn validate_manifest( Ok(ParsedScenario { artifacts: parsed_artifacts, evidence: evidence_by_reference, + physical_evidence: physical_evidence_by_reference, distribution_point_handles, }) } else { @@ -908,6 +924,22 @@ fn evidence_for<'a>( }) } +fn physical_evidence_for( + parsed: &ParsedScenario, + reference: &Value, + context: &str, +) -> Result<(), String> { + let key = evidence_reference_key(reference, context)?; + if parsed.physical_evidence.contains(&key) { + Ok(()) + } else { + Err(format!( + "{context} does not cite an exact physical line: {}:{}-{}", + key.0, key.1, key.2 + )) + } +} + fn evidence_reference_key(reference: &Value, context: &str) -> Result<(String, u32, u32), String> { let artifact_id = required_string(reference, "artifactId", context)?; let line_start = reference["startLine"] @@ -1529,10 +1561,19 @@ fn validate_expected( )); } } - let references = observation["evidence"] - .as_array() - .map(Vec::as_slice) - .unwrap_or_default(); + let references = match required_array(observation, "evidence", observation_id) { + Ok(values) if !values.is_empty() => values, + Ok(_) => { + failures.push(format!( + "{observation_id} has no cited physical source-local evidence" + )); + &[] + } + Err(error) => { + failures.push(error); + &[] + } + }; let mut cited_artifact_ids = BTreeSet::new(); for reference in references { reject_unknown_fields( @@ -1560,7 +1601,7 @@ fn validate_expected( "{observation_id} consumes one physical evidence reference more than once" )); } - if let Err(error) = evidence_for(parsed, reference, observation_id) { + if let Err(error) = physical_evidence_for(parsed, reference, observation_id) { failures.push(error); } } @@ -1570,8 +1611,7 @@ fn validate_expected( .collect::>(); let semantic_match = match classification { Some("ignoredClientEvidence") => { - !references.is_empty() - && cited_artifact_ids == unique_artifact_ids + cited_artifact_ids == unique_artifact_ids && artifacts.iter().all(|artifact| { artifact.role == "client" && artifact.source_id == "client-content-control" @@ -1591,7 +1631,7 @@ fn validate_expected( .iter() .map(|artifact| artifact.rotation_kind.as_str()) .collect::>(); - references.is_empty() + cited_artifact_ids == unique_artifact_ids && artifacts.len() >= 2 && source_ids.len() == 1 && lineages.len() == 1 @@ -1604,7 +1644,7 @@ fn validate_expected( }) } Some("malformedEvidence") => { - references.is_empty() + cited_artifact_ids == unique_artifact_ids && !artifacts.is_empty() && artifacts.iter().all(|artifact| { artifact.role != "client" && artifact.state == "parseFailed" @@ -2536,6 +2576,146 @@ fn source_local_artifact_ids_are_strict_strings_across_classifications() { ); } +#[test] +fn source_local_evidence_is_typed_nonempty_closed_and_physical() { + let client_manifest = + read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); + let client_expected = + read_json("client-only-looking-request", "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 surfaces = [ + ( + "ignoredClientEvidence", + "client-only-looking-request", + &client_manifest, + &client_expected, + 0usize, + json!([{ + "artifactId": "dp-client-control-01-data-transfer", + "startLine": 1, + "endLine": 1 + }]), + ), + ( + "rotationSplit", + "rotation-boundary", + &rotation_manifest, + &rotation_expected, + 0usize, + json!([ + { + "artifactId": "dp-rotation-01-current-fragment", + "startLine": 1, + "endLine": 1 + }, + { + "artifactId": "dp-rotation-02-lo-fragment", + "startLine": 1, + "endLine": 1 + } + ]), + ), + ( + "malformedEvidence", + "rotation-boundary", + &rotation_manifest, + &rotation_expected, + 1usize, + json!([{ + "artifactId": "dp-rotation-03-malformed", + "startLine": 1, + "endLine": 1 + }]), + ), + ]; + let non_array_shapes = [ + ("null", Value::Null), + ("boolean", json!(true)), + ("numeric", json!(7)), + ("string", json!("not-an-evidence-array")), + ("object", json!({"unexpected": "value"})), + ]; + let mut accepted = Vec::new(); + + for (surface, scenario, manifest, expected, observation_index, valid_evidence) in surfaces { + for (shape, invalid_evidence) in &non_array_shapes { + let mut mutated = expected.clone(); + mutated["sourceLocalObservations"][observation_index]["evidence"] = + invalid_evidence.clone(); + if mutation_was_accepted(scenario, manifest, &mutated) { + accepted.push(format!("{surface} accepted {shape} evidence")); + } + } + + let mut empty = expected.clone(); + empty["sourceLocalObservations"][observation_index]["evidence"] = json!([]); + if mutation_was_accepted(scenario, manifest, &empty) { + accepted.push(format!("{surface} accepted an empty evidence array")); + } + + let mut mixed = expected.clone(); + let mut mixed_entries = valid_evidence + .as_array() + .expect("valid source-local evidence is an array") + .clone(); + mixed_entries.extend([ + Value::Null, + json!(true), + json!(7), + json!("not-an-evidence-reference"), + json!({"unexpected": "value"}), + ]); + mixed["sourceLocalObservations"][observation_index]["evidence"] = + Value::Array(mixed_entries); + if mutation_was_accepted(scenario, manifest, &mixed) { + accepted.push(format!("{surface} accepted a mixed evidence array")); + } + + let mut open_reference = expected.clone(); + open_reference["sourceLocalObservations"][observation_index]["evidence"] = + valid_evidence.clone(); + open_reference["sourceLocalObservations"][observation_index]["evidence"][0]["unexpected"] = + json!("value"); + if mutation_was_accepted(scenario, manifest, &open_reference) { + accepted.push(format!( + "{surface} accepted an open evidence-reference object" + )); + } + + let mut missing_line = expected.clone(); + missing_line["sourceLocalObservations"][observation_index]["evidence"] = + valid_evidence.clone(); + missing_line["sourceLocalObservations"][observation_index]["evidence"][0] + .as_object_mut() + .expect("evidence reference is an object") + .remove("endLine"); + if mutation_was_accepted(scenario, manifest, &missing_line) { + accepted.push(format!( + "{surface} accepted an incomplete evidence reference" + )); + } + + let mut unbound_line = expected.clone(); + unbound_line["sourceLocalObservations"][observation_index]["evidence"] = + valid_evidence.clone(); + unbound_line["sourceLocalObservations"][observation_index]["evidence"][0]["startLine"] = + json!(99); + unbound_line["sourceLocalObservations"][observation_index]["evidence"][0]["endLine"] = + json!(99); + if mutation_was_accepted(scenario, manifest, &unbound_line) { + accepted.push(format!("{surface} accepted an unbound physical line")); + } + } + + assert!( + accepted.is_empty(), + "malformed source-local evidence was accepted: {accepted:?}" + ); +} + #[test] fn coverage_gap_ids_are_typed_nonempty_unique_and_physical() { let incomplete_manifest = read_json("incomplete", "manifest.json").expect("manifest loads"); diff --git a/docs/sccm/preparation/issue-329-distribution-point-corpus.md b/docs/sccm/preparation/issue-329-distribution-point-corpus.md index 42169ec4a..f62c3ab96 100644 --- a/docs/sccm/preparation/issue-329-distribution-point-corpus.md +++ b/docs/sccm/preparation/issue-329-distribution-point-corpus.md @@ -114,7 +114,10 @@ The outcome rules are conservative: 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 + 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 From 8c4e0ceba2355a41bfd6a329e4a110aee4fb3de8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 03:02:47 -0400 Subject: [PATCH 099/422] test(sccm): close provider corpus review gaps --- ...ider_and_admin_service_fixture_contract.rs | 373 +++++++++++++++--- 1 file changed, 310 insertions(+), 63 deletions(-) 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 index 5bf71d4e3..86dfdbe5b 100644 --- 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 @@ -97,6 +97,14 @@ fn safe_opaque_token(value: &str, prefix: &str, max_suffix_len: usize) -> bool { }) } +fn safe_synthetic_lineage(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + fn expected_sanitized_source_path( source_id: Option<&str>, rotation: &Value, @@ -118,27 +126,35 @@ fn expected_sanitized_source_path( } } -fn public_projection_contains_sensitive_shape(value: &Value) -> bool { +fn exported_projection_contains_private_shape(value: &Value) -> bool { match value { Value::String(value) => { let value = value.to_ascii_lowercase(); - [ - "@", - "bearer", - "select ", - "http://", - "https://", - "/adminservice", - ] - .iter() - .any(|term| value.contains(term)) + value.chars().any(char::is_control) + || value.contains('@') + || value.contains('\\') + || [ + "bearer ", + "select ", + "delete ", + "insert ", + "update ", + "drop ", + "http://", + "https://", + "ghp_", + "github_pat_", + "eyj", + ] + .iter() + .any(|term| value.contains(term)) } Value::Array(values) => values .iter() - .any(public_projection_contains_sensitive_shape), + .any(exported_projection_contains_private_shape), Value::Object(values) => values .values() - .any(public_projection_contains_sensitive_shape), + .any(exported_projection_contains_private_shape), _ => false, } } @@ -325,22 +341,52 @@ fn expected_transaction_ids(scenario: &str) -> &'static [&'static str] { } } -fn expected_outcomes(scenario: &str) -> &'static [(&'static str, &'static str, &'static str)] { +fn expected_outcomes( + scenario: &str, +) -> &'static [(&'static str, &'static str, &'static str, &'static str)] { match scenario { "admin-service-auth-failure" | "admin-service-backend-failure" => { - &[("failed", "confirmedFailure", "high")] + &[("failed", "confirmedFailure", "high", "high")] } "admin-service-success" | "iis-supplemental" | "provider-success" => { - &[("succeeded", "success", "high")] + &[("succeeded", "success", "high", "high")] } "privacy-redaction" => &[ - ("succeeded", "success", "high"), - ("succeeded", "success", "high"), + ("succeeded", "success", "high", "high"), + ("succeeded", "success", "high", "high"), ], "provider-authz-denied" | "provider-query-failure" => { - &[("failed", "confirmedFailure", "high")] + &[("failed", "confirmedFailure", "high", "high")] } - "provider-timeout" | "incomplete" => &[("incomplete", "insufficientEvidence", "low")], + "provider-timeout" | "incomplete" => { + &[("incomplete", "insufficientEvidence", "low", "low")] + } + "rotation-boundary" => &[], + _ => &[], + } +} + +fn expected_public_summaries(scenario: &str) -> &'static [&'static str] { + match scenario { + "admin-service-auth-failure" => &["Admin Service authentication was explicitly rejected."], + "admin-service-backend-failure" => { + &["Admin Service recorded an explicit backend operation failure."] + } + "admin-service-success" => { + &["Admin Service request completed with explicit terminal evidence."] + } + "iis-supplemental" => &["Admin Service evidence independently records a terminal success."], + "incomplete" => { + &["Admin Service evidence stops before an explicit response or terminal outcome."] + } + "privacy-redaction" => &[ + "Admin Service privacy fixture completed with redacted public evidence.", + "Provider privacy fixture completed with redacted public evidence.", + ], + "provider-authz-denied" => &["Provider authorization was explicitly denied."], + "provider-query-failure" => &["Provider operation recorded an explicit terminal failure."], + "provider-success" => &["Provider operation completed with explicit terminal evidence."], + "provider-timeout" => &["Provider evidence stops before an explicit terminal outcome."], "rotation-boundary" => &[], _ => &[], } @@ -435,10 +481,36 @@ fn expected_source_local_ids(scenario: &str) -> &'static [&'static str] { } } -fn expected_requested_sources(scenario: &str) -> &'static [&'static str] { +fn expected_source_local_reasons(scenario: &str) -> &'static [&'static str] { + match scenario { + "iis-supplemental" => &[ + "Scoped IIS evidence is optional context and cannot create an Admin Service transaction.", + ], + "privacy-redaction" => &[ + "Caller and endpoint details remain outside public keys and summaries.", + "Caller, authorization, and query details remain outside public keys and summaries.", + ], + "rotation-boundary" => { + &["Split rotation fragments and an unknown version cannot form an exact request key."] + } + _ => &[], + } +} + +fn expected_artifact_requests(scenario: &str) -> &'static [(&'static str, &'static str)] { match scenario { - "incomplete" => &["server-admin-service"], - "provider-timeout" | "rotation-boundary" => &["server-provider"], + "incomplete" => &[( + "server-admin-service", + "Capture the bounded Admin Service source lineage for the exact request key and terminal outcome.", + )], + "provider-timeout" => &[( + "server-provider", + "Capture the bounded Provider source lineage for the exact request key and terminal outcome.", + )], + "rotation-boundary" => &[( + "server-provider", + "Capture one bounded complete Provider rotation with known version provenance.", + )], _ => &[], } } @@ -591,6 +663,7 @@ fn schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec Vec Vec Vec Vec Vec Vec Vec 0 + && end >= start + && physical_line_counts + .get(artifact_id) + .is_some_and(|line_count| u64::from(end) <= *line_count) + }); if !object_has_only(reference, &["artifactId", "startLine", "endLine"]) || reference["artifactId"] .as_str() @@ -1063,19 +1199,13 @@ fn schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec Vec Vec Vec Vec Vec>(); @@ -1400,11 +1555,25 @@ fn request_transactions_are_exact_cited_ordered_and_layer_local() { .expect("evidence is an array") { let artifact_id = reference["artifactId"].as_str().unwrap_or_default(); - let record_key = ( - artifact_id.to_owned(), - reference["startLine"].as_u64().unwrap_or_default() as u32, - reference["endLine"].as_u64().unwrap_or_default() as u32, - ); + let Some(start_line) = reference["startLine"] + .as_u64() + .and_then(|line| u32::try_from(line).ok()) + else { + failures.push(format!( + "{scenario}/{observation_id}: citation start line exceeds u32" + )); + continue; + }; + let Some(end_line) = reference["endLine"] + .as_u64() + .and_then(|line| u32::try_from(line).ok()) + else { + failures.push(format!( + "{scenario}/{observation_id}: citation end line exceeds u32" + )); + continue; + }; + let record_key = (artifact_id.to_owned(), start_line, end_line); let Some(record) = records.get(&record_key) else { failures.push(format!( "{scenario}/{observation_id}: citation is not one logical CCM record" @@ -1877,3 +2046,81 @@ fn review_privacy_topology_profile_and_citation_mutations_fail_closed() { assert!(accepted.is_empty(), "accepted mutations: {accepted:#?}"); } + +#[test] +fn overflowing_transaction_citation_lines_fail_closed() { + let manifest = read_json("provider-success", "manifest.json").unwrap(); + let mut expected = read_json("provider-success", "expected.json").unwrap(); + let wrapped_line = u64::from(u32::MAX) + 2; + expected["transactions"][0]["observations"][0]["evidence"][0]["startLine"] = + Value::from(wrapped_line); + expected["transactions"][0]["observations"][0]["evidence"][0]["endLine"] = + Value::from(wrapped_line); + + assert!( + !schema_failures("provider-success", &manifest, &expected).is_empty(), + "u64 citation lines that wrap to a different u32 logical record were accepted" + ); +} + +#[test] +fn exported_manifest_and_expected_strings_fail_closed_on_private_shapes() { + let manifest = read_json("provider-success", "manifest.json").unwrap(); + let expected = read_json("provider-success", "expected.json").unwrap(); + let mut accepted = Vec::new(); + + let mut private_manifest = manifest.clone(); + private_manifest["artifacts"][0]["rotation"]["lineageId"] = + Value::String("synthetic.user@example.invalid".to_owned()); + if schema_failures("provider-success", &private_manifest, &expected).is_empty() { + accepted.push("manifest rotation lineage containing an email identity"); + } + + let mut private_expected = expected.clone(); + private_expected["transactions"][0]["publicSummary"] = Value::String( + r"CONTOSO\alice issued DELETE FROM SMS_R_System with ghp_abcdefghijklmnopqrstuvwxyz0123456789" + .to_owned(), + ); + if schema_failures("provider-success", &manifest, &private_expected).is_empty() { + accepted.push("public summary containing identity, query, and token shapes"); + } + + assert!(accepted.is_empty(), "accepted mutations: {accepted:#?}"); +} + +#[test] +fn split_rotation_members_with_divergent_lineage_fail_closed() { + let mut manifest = read_json("rotation-boundary", "manifest.json").unwrap(); + let expected = read_json("rotation-boundary", "expected.json").unwrap(); + manifest["artifacts"][1]["rotation"]["lineageId"] = + Value::String("different-lineage".to_owned()); + + assert!( + !schema_failures("rotation-boundary", &manifest, &expected).is_empty(), + "one source-local split rotation accepted divergent lineage provenance" + ); +} + +#[test] +fn incomplete_invalid_offset_transaction_cannot_claim_high_ceiling() { + let manifest = read_json("provider-timeout", "manifest.json").unwrap(); + let mut expected = read_json("provider-timeout", "expected.json").unwrap(); + expected["transactions"][0]["confidenceCeiling"] = Value::String("high".to_owned()); + + assert!( + !schema_failures("provider-timeout", &manifest, &expected).is_empty(), + "incomplete invalid-offset evidence accepted a high confidence ceiling" + ); +} + +#[test] +fn generic_artifact_request_reason_fails_closed() { + let manifest = read_json("provider-timeout", "manifest.json").unwrap(); + let mut expected = read_json("provider-timeout", "expected.json").unwrap(); + expected["artifactRequests"][0]["reason"] = Value::String("Collect logs.".to_owned()); + + assert!( + !schema_failures("provider-timeout", &manifest, &expected).is_empty(), + "generic request prose lost the exact unresolved request and terminal basis" + ); +} From 8809207b0d137e8e3179ae5b8059410e7e9d5693 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 03:15:19 -0400 Subject: [PATCH 100/422] test(sccm): close hierarchy provenance gaps --- ...rarchy_and_replication_fixture_contract.rs | 458 ++++++++++++++++-- .../issue-331-hierarchy-replication-corpus.md | 49 +- 2 files changed, 450 insertions(+), 57 deletions(-) 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 index 7fb5bfe5c..cff3f9616 100644 --- 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 @@ -32,6 +32,7 @@ const STATE_CHAIN: &[&str] = &[ ]; const EXACT_PROFILE: &str = "hierarchy-server-5.00.test-v1"; +const EXACT_SOURCE_VERSION: &str = "5.00.TEST.0001"; fn corpus_root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -84,6 +85,15 @@ fn safe_segmented_path(value: &str, prefix: &str) -> bool { }) } +fn safe_server_handle(value: &str) -> bool { + value.strip_prefix("safe:server:").is_some_and(|payload| { + !payload.is_empty() + && payload + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + }) +} + fn coverage_state(value: &str) -> Option { match value { "captured" => Some(SccmCoverageState::Captured), @@ -463,9 +473,125 @@ struct ArtifactRequestBasis { basenames: Vec, } -fn request_direction_matches(request_direction: &str, artifact_direction: &str) -> bool { - request_direction == artifact_direction - || (request_direction == "both" && matches!(artifact_direction, "origin" | "target")) +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| { + matches!( + ( + artifact["originalBasename"].as_str(), + artifact["rotation"]["kind"].as_str(), + artifact["rotation"].get("value"), + ), + (Some("sender.log"), Some("current"), None) + | (Some("sender.lo_"), Some("lo_"), None) + ) + }); + 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 { @@ -540,7 +666,6 @@ fn artifact_request_failures(scenario: &str, manifest: &Value, expected: &Value) failures.push(format!("{scenario}: artifact requests are not an array")); return failures; }; - let artifacts = manifest["artifacts"].as_array(); let target_sites = declared_target_site_codes(manifest); let request_keys = requests .iter() @@ -619,40 +744,8 @@ fn artifact_request_failures(scenario: &str, manifest: &Value, expected: &Value) invalid_offset_request_basis(scenario, manifest).as_ref() == Some(&actual_basis) } Some(reason @ ("coverageAbsent" | "coverageCapped" | "coverageRotationSplit")) => { - let expected_state = match reason { - "coverageAbsent" => Some("absent"), - "coverageCapped" => Some("capped"), - "coverageRotationSplit" => None, - _ => unreachable!(), - }; - let matching = artifacts - .into_iter() - .flatten() - .filter(|artifact| { - artifact["sourceId"].as_str() == source_id - && artifact["direction"] - .as_str() - .is_some_and(|artifact_direction| { - request_direction_matches( - direction.unwrap_or_default(), - artifact_direction, - ) - }) - && expected_state.map_or_else( - || artifact["rotation"]["fragmentComplete"] == false, - |state| artifact["captureState"] == state, - ) - }) - .collect::>(); - let matching_basenames = matching - .iter() - .filter_map(|artifact| artifact["originalBasename"].as_str()) - .collect::>() - .into_iter() - .collect::>(); - !matching.is_empty() - && (reason != "coverageRotationSplit" || matching.len() >= 2) - && matching_basenames == basenames + coverage_request_basis(manifest, source_id.unwrap_or_default(), reason).as_ref() + == Some(&actual_basis) } _ => false, }; @@ -696,16 +789,16 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val failures.push("manifest contains an unsupported field or shape".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) - || topology["originHostHandle"] - .as_str() - .is_none_or(|value| !value.starts_with("safe:server:")) - || primary_target_host.is_none_or(|value| !value.starts_with("safe:server:")) + || 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()); } @@ -730,7 +823,8 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val 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| !value.starts_with("safe:server:")) + || 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()) { @@ -783,8 +877,11 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val { failures.push("artifact contains an unsupported field or shape".to_owned()); } - let Some(artifact_id) = artifact["artifactId"].as_str() else { - failures.push("artifactId is not a string".to_owned()); + let Some(artifact_id) = artifact["artifactId"] + .as_str() + .filter(|artifact_id| !artifact_id.is_empty()) + else { + failures.push("artifactId is not a non-empty string".to_owned()); continue; }; artifact_ids.push(artifact_id); @@ -794,9 +891,10 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val } if artifact["producerRole"] != "siteServer" || !matches!(artifact["direction"].as_str(), Some("origin" | "target")) - || !artifact["sourceVersion"] + || artifact["sourceVersion"].as_str() != Some(EXACT_SOURCE_VERSION) + || artifact["collectedUtc"] .as_str() - .is_some_and(|value| value.starts_with("5.00.TEST.") && value.len() > 10) + .is_none_or(|value| DateTime::parse_from_rfc3339(value).is_err()) || !artifact["sanitizedSourcePath"] .as_str() .is_some_and(|value| safe_segmented_path(value, "SYNTHETIC://")) @@ -940,7 +1038,10 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val if !object_has_only(row, &["artifactId", "state"]) { return None; } - row["artifactId"].as_str().zip(row["state"].as_str()) + row["artifactId"] + .as_str() + .filter(|artifact_id| !artifact_id.is_empty()) + .zip(row["state"].as_str()) }) .collect::>>() }); @@ -1070,7 +1171,13 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val } let gap_values = transaction["coverageGapArtifactIds"].as_array(); let gap_ids = gap_values - .map(|values| values.iter().filter_map(Value::as_str).collect::>()) + .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(); @@ -1128,13 +1235,19 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val ) { 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"]) - || reference["artifactId"].as_str().is_none() + || reference["artifactId"].as_str().is_none_or(str::is_empty) || reference["startLine"] .as_u64() .and_then(|value| u32::try_from(value).ok()) @@ -1158,8 +1271,67 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val if observation_ids != expected_observation_ids(scenario) { failures.push("observation identity/cardinality matrix changed".to_owned()); } - let source_local_ids = expected["sourceLocalObservations"] - .as_array() + 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 references = observation["evidence"].as_array(); + if references.is_none() { + failures.push("source-local evidence is not an array".to_owned()); + } + for reference in references.into_iter().flatten() { + if !object_has_only(reference, &["artifactId", "startLine", "endLine"]) + || reference["artifactId"].as_str().is_none_or(str::is_empty) + || reference["startLine"] + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .is_none() + || reference["endLine"] + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .is_none() + { + failures.push("source-local evidence reference is not exact and typed".to_owned()); + } + } + } + let source_local_ids = source_local_observations .into_iter() .flatten() .filter_map(|observation| observation["observationId"].as_str()) @@ -2279,3 +2451,185 @@ fn hierarchy_artifact_request_mutations_fail_closed() { "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:?}" + ); +} diff --git a/docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md b/docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md index 8294c706c..e46115fd1 100644 --- a/docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md +++ b/docs/sccm/preparation/issue-331-hierarchy-replication-corpus.md @@ -29,6 +29,13 @@ 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, @@ -47,17 +54,49 @@ 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. +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. -Every transaction gap ID must resolve to exactly one typed non-captured -manifest coverage row; missing, duplicated, malformed, or unknown rows fail -closed. Bounded follow-up requests name only the relevant hierarchy source, -direction, target site, and basenames. + +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: From 71158c1757fc4f62e40ec82be57dae35571e00bb Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 03:24:10 -0400 Subject: [PATCH 101/422] test(sccm): close distribution point contract gaps --- ...ver_distribution_point_fixture_contract.rs | 603 +++++++++++++++++- 1 file changed, 568 insertions(+), 35 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index f8fc009b7..2cb962810 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -326,6 +326,31 @@ fn path_fingerprint_is_safe(path_fingerprint: &str) -> bool { }) } +fn artifact_id_is_safe(artifact_id: &str) -> bool { + artifact_id.len() <= 128 && path_segment_is_safe(artifact_id) +} + +fn request_id_is_safe(request_id: &str) -> bool { + request_id + .strip_prefix("client-request-") + .is_some_and(|suffix| suffix.len() <= 64 && path_segment_is_safe(suffix)) +} + +fn coverage_request_reason(artifact: &ParsedArtifact) -> Option<&'static str> { + match artifact.state.as_str() { + "absent" => Some("coverageAbsent"), + "accessDenied" => Some("coverageAccessDenied"), + "capped" => Some("coverageCapped"), + "parseFailed" => Some("coverageMalformed"), + _ if artifact.fragment_complete == Some(false) => Some("coverageRotationSplit"), + _ => None, + } +} + +fn artifact_has_incomplete_coverage(artifact: &ParsedArtifact) -> bool { + artifact.state != "captured" || artifact.fragment_complete == Some(false) +} + fn validate_manifest( scenario_root: &std::path::Path, manifest: &Value, @@ -485,13 +510,18 @@ fn validate_manifest( let mut physical_source_identities = BTreeSet::new(); let mut path_fingerprints = BTreeSet::new(); for artifact in artifacts { - let artifact_id = match required_string(artifact, "artifactId", "artifact") { + let artifact_id = match required_nonempty_string(artifact, "artifactId", "artifact") { Ok(value) => value, Err(error) => { failures.push(error); continue; } }; + if !artifact_id_is_safe(artifact_id) { + failures.push(format!( + "artifact {artifact_id} does not use a bounded stable artifact ID" + )); + } let context = format!("artifact {artifact_id}"); let source_id = match required_string(artifact, "sourceId", &context) { Ok(value) => value, @@ -613,6 +643,31 @@ fn validate_manifest( } }; let physical_capture = matches!(state, "captured" | "capped" | "parseFailed"); + 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 { + failures.push(format!( + "{artifact_id} was collected after the canonical bundle capture" + )); + } + Some(value) + } + Err(error) => { + failures.push(error); + None + } + }; + let encoding = artifact["encoding"].as_str(); + if physical_capture && encoding.is_none_or(str::is_empty) { + failures.push(format!( + "{artifact_id} physical capture lacks encoding provenance" + )); + } let fragment_complete = if physical_capture { match required_bool(&artifact["rotation"], "fragmentComplete", &context) { Ok(value) => Some(value), @@ -778,7 +833,7 @@ fn validate_manifest( ), rotation: rotation_model.clone(), coverage: coverage_model.clone(), - encoding: artifact["encoding"].as_str().map(str::to_owned), + encoding: encoding.map(str::to_owned), }; let normalized = normalize_ccm_artifact(artifact_model, &content); if fragment_complete == Some(false) && !normalized.is_empty() { @@ -806,10 +861,6 @@ fn validate_manifest( "{artifact_id} cites evidence later than the canonical bundle capture" )); } - let artifact_collected_utc = artifact["collectedUtc"] - .as_str() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.timestamp_millis()); if artifact_collected_utc.is_none() || artifact_collected_utc.is_some_and(|value| value > captured_utc) || record @@ -847,6 +898,25 @@ fn validate_manifest( "{artifact_id} record escapes its declared workflow-subject scope" )); } + let identity_fields_are_safe = match role { + "client" => { + fields.get("ClientHandle").map(String::as_str) + == Some(EXACT_CLIENT) + && fields + .get("RequestId") + .is_some_and(|value| request_id_is_safe(value)) + } + "siteServer" | "distributionPoint" => { + !fields.contains_key("ClientHandle") + && !fields.contains_key("RequestId") + } + _ => false, + }; + if !identity_fields_are_safe { + failures.push(format!( + "{artifact_id} exposes identity-bearing fields outside the approved opaque role namespace" + )); + } } Err(error) => failures.push(format!("{artifact_id}: {error}")), } @@ -1059,7 +1129,7 @@ fn validate_expected( } if expected["stateChain"] .as_array() - .map(|values| values.iter().filter_map(Value::as_str).collect::>()) + .and_then(|values| values.iter().map(Value::as_str).collect::>>()) .as_deref() != Some(STATE_CHAIN) { @@ -1139,6 +1209,7 @@ fn validate_expected( let mut seen_transaction_ids = BTreeSet::new(); let mut seen_observation_ids = BTreeSet::new(); let mut consumed_evidence = BTreeSet::new(); + let mut required_incomplete_requests = BTreeSet::new(); for transaction in transactions { let transaction_id = match required_string(transaction, "transactionId", "transaction") { Ok(value) => value, @@ -1219,6 +1290,11 @@ fn validate_expected( continue; } }; + if observations.is_empty() { + failures.push(format!( + "{transaction_id} has an exact correlation-eligible key without cited logical records" + )); + } let observation_order = observations .iter() .filter_map(|observation| observation["observationId"].as_str()) @@ -1233,8 +1309,10 @@ fn validate_expected( let mut latest_success: Option = None; let mut terminal_success = false; + let mut terminal_success_phase = None; let mut terminal_failure = false; let mut terminal_deferred = false; + let mut cites_capped_evidence = false; let mut previous_utc = i64::MIN; let mut previous_phase = 0usize; for observation in observations { @@ -1313,7 +1391,10 @@ fn validate_expected( && artifact .workflow_subject_handle .as_deref() - .is_none_or(|handle| handle == key_fields["DpHandle"]) => {} + .is_none_or(|handle| handle == key_fields["DpHandle"]) => + { + cites_capped_evidence |= artifact.state == "capped"; + } _ => failures.push(format!( "{observation_id} cites an artifact that cannot own phase {phase}" )), @@ -1359,6 +1440,12 @@ fn validate_expected( match (disposition, terminal) { ("succeeded", true) => { latest_success = latest_success.max(phase_index); + if terminal_success_phase.is_some() { + failures.push(format!( + "{transaction_id} contains more than one terminal success" + )); + } + terminal_success_phase = phase_index; terminal_success = true; } ("succeeded", false) => latest_success = latest_success.max(phase_index), @@ -1388,19 +1475,26 @@ fn validate_expected( match (state, classification) { ("succeeded", "success") if terminal_success + && terminal_success_phase + == STATE_CHAIN + .iter() + .position(|phase| *phase == "serveOrReport") && computed_last_success == Some("serveOrReport") && !terminal_failure + && !cites_capped_evidence && confidence == "high" && confidence_ceiling == "high" => {} ("failed", "confirmedFailure") if terminal_failure && !terminal_success + && !cites_capped_evidence && confidence == "high" && confidence_ceiling == "high" => {} ("deferred", "blockedOrDeferred") if terminal_deferred && !terminal_failure && !terminal_success + && !cites_capped_evidence && confidence == "medium" && confidence_ceiling == "medium" => {} ("incomplete", "insufficientEvidence") @@ -1438,9 +1532,9 @@ fn validate_expected( "{transaction_id} coverage gaps must be sorted and unique" )); } - for artifact_id in gap_ids { - match parsed.artifacts.get(artifact_id) { - Some(artifact) if artifact.state != "captured" => {} + for artifact_id in &gap_ids { + match parsed.artifacts.get(*artifact_id) { + Some(artifact) if artifact_has_incomplete_coverage(artifact) => {} _ => failures.push(format!( "{transaction_id} coverage gap {artifact_id} is absent or complete" )), @@ -1457,6 +1551,35 @@ fn validate_expected( "{transaction_id} incomplete state lacks a bounded noncomplete next source" )); } + let declared_gap_ids = gap_ids.iter().copied().collect::>(); + let expected_gap_ids = parsed + .artifacts + .iter() + .filter(|(_, artifact)| { + Some(artifact.source_id.as_str()) == next_source + && artifact_has_incomplete_coverage(artifact) + }) + .map(|(artifact_id, _)| artifact_id.as_str()) + .collect::>(); + if declared_gap_ids.is_empty() || declared_gap_ids != expected_gap_ids { + failures.push(format!( + "{transaction_id} incomplete state lacks the exact physical coverage gaps" + )); + } + for artifact_id in declared_gap_ids { + let Some(artifact) = parsed.artifacts.get(artifact_id) else { + continue; + }; + match coverage_request_reason(artifact) { + Some(reason_code) => { + required_incomplete_requests + .insert((artifact.source_id.clone(), reason_code.to_owned())); + } + None => failures.push(format!( + "{transaction_id} gap {artifact_id} has no bounded artifact-request reason" + )), + } + } } else if !transaction["nextSourceId"].is_null() { failures.push(format!( "{transaction_id} terminal/deferred state invents a next source" @@ -1575,6 +1698,7 @@ fn validate_expected( } }; let mut cited_artifact_ids = BTreeSet::new(); + let mut reference_order = Vec::new(); for reference in references { reject_unknown_fields( reference, @@ -1594,17 +1718,25 @@ fn validate_expected( )); } } - if evidence_reference_key(reference, observation_id) - .is_ok_and(|key| !consumed_evidence.insert(key)) - { - failures.push(format!( - "{observation_id} consumes one physical evidence reference more than once" - )); + if let Ok(key) = evidence_reference_key(reference, observation_id) { + reference_order.push(key.clone()); + if !consumed_evidence.insert(key) { + failures.push(format!( + "{observation_id} consumes one physical evidence reference more than once" + )); + } } if let Err(error) = physical_evidence_for(parsed, reference, observation_id) { failures.push(error); } } + let mut sorted_reference_order = reference_order.clone(); + sorted_reference_order.sort(); + if reference_order != sorted_reference_order { + failures.push(format!( + "{observation_id} physical evidence is not canonically ordered" + )); + } let artifacts = artifact_ids .iter() .filter_map(|artifact_id| parsed.artifacts.get(*artifact_id)) @@ -1668,27 +1800,30 @@ fn validate_expected( }; let mut request_order = Vec::new(); for request in requests { - let source_id = - required_string(request, "sourceId", "artifactRequest").unwrap_or("invalid"); - let reason_code = - required_string(request, "reasonCode", "artifactRequest").unwrap_or("invalid"); + let source_id = required_string(request, "sourceId", "artifactRequest") + .unwrap_or("invalid") + .to_owned(); + let reason_code = required_string(request, "reasonCode", "artifactRequest") + .unwrap_or("invalid") + .to_owned(); reject_unknown_fields( request, &["sourceId", "reasonCode"], "artifactRequest", &mut failures, ); - request_order.push((source_id, reason_code)); - if !matches!(source_id, "server-dp-distribution" | "server-dp-serve") - || !matches!( - reason_code, - "coverageAbsent" - | "coverageAccessDenied" - | "coverageCapped" - | "coverageMalformed" - | "coverageRotationSplit" - ) - || request.get("reason").is_some() + request_order.push((source_id.clone(), reason_code.clone())); + if !matches!( + source_id.as_str(), + "server-dp-distribution" | "server-dp-serve" + ) || !matches!( + reason_code.as_str(), + "coverageAbsent" + | "coverageAccessDenied" + | "coverageCapped" + | "coverageMalformed" + | "coverageRotationSplit" + ) || request.get("reason").is_some() { failures.push(format!( "artifact request is not a bounded versioned source/reason code: {source_id}/{reason_code}" @@ -1696,7 +1831,7 @@ fn validate_expected( } let matching_coverage = parsed.artifacts.values().any(|artifact| { artifact.source_id == source_id - && match reason_code { + && match reason_code.as_str() { "coverageAbsent" => artifact.state == "absent", "coverageAccessDenied" => artifact.state == "accessDenied", "coverageCapped" => artifact.state == "capped", @@ -1716,8 +1851,27 @@ fn validate_expected( } let mut sorted_request_order = request_order.clone(); sorted_request_order.sort_unstable(); - if request_order != sorted_request_order { - failures.push("artifact requests are not deterministically sorted".to_owned()); + let declared_requests = request_order.iter().cloned().collect::>(); + if request_order != sorted_request_order || declared_requests.len() != request_order.len() { + failures.push("artifact requests are not sorted and unique".to_owned()); + } + let expected_requests = parsed + .artifacts + .values() + .filter_map(|artifact| { + coverage_request_reason(artifact) + .map(|reason| (artifact.source_id.clone(), reason.to_owned())) + }) + .collect::>(); + if declared_requests != expected_requests { + failures.push(format!( + "artifact requests are not the exact bounded coverage projection: {declared_requests:?} != {expected_requests:?}" + )); + } + if !required_incomplete_requests.is_subset(&declared_requests) { + failures.push( + "incomplete transactions lack requests matching their exact physical gaps".to_owned(), + ); } if expected["clientCausalClaims"] != json!([]) @@ -1834,6 +1988,84 @@ fn mutation_was_accepted(scenario: &str, manifest: &Value, expected: &Value) -> validate_scenario_values(scenario, manifest, expected).is_ok() } +struct TemporaryScenario { + root: std::path::PathBuf, +} + +impl Drop for TemporaryScenario { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn copy_fixture_tree(source: &std::path::Path, destination: &std::path::Path) { + std::fs::create_dir_all(destination).expect("temporary fixture directory is created"); + for entry in std::fs::read_dir(source).expect("fixture directory is readable") { + let entry = entry.expect("fixture directory entry is readable"); + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + if source_path.is_dir() { + copy_fixture_tree(&source_path, &destination_path); + } else { + std::fs::copy(&source_path, &destination_path) + .expect("fixture file is copied into the temporary scenario"); + } + } +} + +fn temporary_scenario(scenario: &str) -> TemporaryScenario { + static NEXT_TEMP_SCENARIO: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let sequence = NEXT_TEMP_SCENARIO.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "cmtraceopen-sccm-329-{}-{scenario}-{sequence}", + std::process::id() + )); + copy_fixture_tree(&corpus_root().join(scenario), &root); + TemporaryScenario { root } +} + +fn mutation_at_root_was_accepted( + scenario: &str, + scenario_root: &std::path::Path, + manifest: &Value, + expected: &Value, +) -> bool { + validate_manifest(scenario_root, manifest) + .and_then(|parsed| validate_expected(scenario, manifest, expected, &parsed)) + .is_ok() +} + +fn replace_fixture_text( + scenario_root: &std::path::Path, + relative_path: &str, + original: &str, + replacement: &str, +) { + let path = scenario_root.join(relative_path); + let contents = std::fs::read_to_string(&path).expect("temporary fixture is readable"); + assert_eq!( + contents.matches(original).count(), + 1, + "fixture mutation must identify exactly one raw marker" + ); + std::fs::write(&path, contents.replacen(original, replacement, 1)) + .expect("temporary fixture mutation is written"); +} + +fn refresh_artifact_bytes( + manifest: &mut Value, + artifact_index: usize, + scenario_root: &std::path::Path, +) { + let relative_path = manifest["artifacts"][artifact_index]["relativePath"] + .as_str() + .expect("physical artifact has a relative path"); + let byte_count = std::fs::metadata(scenario_root.join(relative_path)) + .expect("mutated physical artifact is readable") + .len(); + manifest["artifacts"][artifact_index]["bytesCopied"] = json!(byte_count); +} + #[test] fn exact_content_version_dp_topology_and_terminal_evidence_fail_closed() { let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); @@ -2752,3 +2984,304 @@ fn coverage_gap_ids_are_typed_nonempty_unique_and_physical() { "malformed coverage-gap artifact IDs were accepted: {accepted:?}" ); } + +#[test] +fn capped_transaction_evidence_cannot_retain_high_confidence_terminal_health() { + let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + for (label, artifact_index) in [("earlier phase", 0usize), ("terminal phase", 2usize)] { + let mut manifest = healthy_manifest.clone(); + let mut expected = healthy_expected.clone(); + let artifact_id = manifest["artifacts"][artifact_index]["artifactId"].clone(); + let bytes_copied = manifest["artifacts"][artifact_index]["bytesCopied"].clone(); + manifest["artifacts"][artifact_index]["captureState"] = json!("capped"); + manifest["artifacts"][artifact_index]["collectionLimit"] = + json!({"byteLimit": bytes_copied, "limitApplied": true}); + expected["coverage"][artifact_index]["state"] = json!("capped"); + expected["transactions"][0]["coverageGapArtifactIds"] = json!([artifact_id]); + expected["artifactRequests"] = json!([{ + "sourceId": "server-dp-distribution", + "reasonCode": "coverageCapped" + }]); + + if mutation_was_accepted("healthy-package", &manifest, &expected) { + accepted.push(label); + } + } + + assert!( + accepted.is_empty(), + "capped transaction evidence retained high-confidence success: {accepted:?}" + ); +} + +#[test] +fn terminal_success_is_bound_to_the_cited_serve_or_report_record() { + let temporary = temporary_scenario("healthy-package"); + let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let mut expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let relative_path = manifest["artifacts"][2]["relativePath"] + .as_str() + .expect("provider artifact has a path") + .to_owned(); + + replace_fixture_text( + &temporary.root, + &relative_path, + "Phase=validate; Disposition=succeeded; Terminal=false;", + "Phase=validate; Disposition=succeeded; Terminal=true;", + ); + replace_fixture_text( + &temporary.root, + &relative_path, + "Phase=serveOrReport; Disposition=succeeded; Terminal=true;", + "Phase=serveOrReport; Disposition=succeeded; Terminal=false;", + ); + refresh_artifact_bytes(&mut manifest, 2, &temporary.root); + expected["transactions"][0]["observations"][3]["terminal"] = json!(true); + expected["transactions"][0]["observations"][5]["terminal"] = json!(false); + + assert!( + !mutation_at_root_was_accepted("healthy-package", &temporary.root, &manifest, &expected,), + "an earlier terminal success survived later nonterminal ServeOrReport evidence" + ); +} + +#[test] +fn correlation_eligible_incomplete_output_requires_evidence_gaps_and_requests() { + 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 mut uncited_key = expected.clone(); + uncited_key["transactions"][0]["observations"] = json!([]); + uncited_key["transactions"][0]["lastSuccessfulPhase"] = Value::Null; + if mutation_was_accepted("incomplete", &manifest, &uncited_key) { + accepted.push("correlation-eligible exact key with zero cited logical records"); + } + + let mut no_gaps = expected.clone(); + no_gaps["transactions"][0]["coverageGapArtifactIds"] = json!([]); + if mutation_was_accepted("incomplete", &manifest, &no_gaps) { + accepted.push("insufficientEvidence transaction with no physical gaps"); + } + + let mut no_requests = expected.clone(); + no_requests["artifactRequests"] = json!([]); + if mutation_was_accepted("incomplete", &manifest, &no_requests) { + accepted.push("insufficientEvidence transaction with no bounded request"); + } + + assert!( + accepted.is_empty(), + "incomplete output escaped evidence-first coverage requirements: {accepted:?}" + ); +} + +#[test] +fn identity_bearing_fixture_fields_are_bounded_role_local_and_not_public() { + let client_expected = + read_json("client-only-looking-request", "expected.json").expect("expected loads"); + let public_json = serde_json::to_string(&client_expected).expect("public output serializes"); + for forbidden in ["ClientHandle", "RequestId", "RealUser", "RealRequest"] { + assert!( + !public_json.contains(forbidden), + "public expected JSON exposes raw identity marker {forbidden}" + ); + } + + let mut accepted = Vec::new(); + for (label, original, replacement) in [ + ( + "unbounded client handle", + "ClientHandle=safe:client:lab-client-01", + "ClientHandle=RealUser", + ), + ( + "unbounded request ID", + "RequestId=client-request-01", + "RequestId=RealRequest", + ), + ] { + let temporary = temporary_scenario("client-only-looking-request"); + let mut manifest = + read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); + let relative_path = manifest["artifacts"][0]["relativePath"] + .as_str() + .expect("client artifact has a path") + .to_owned(); + replace_fixture_text(&temporary.root, &relative_path, original, replacement); + refresh_artifact_bytes(&mut manifest, 0, &temporary.root); + if mutation_at_root_was_accepted( + "client-only-looking-request", + &temporary.root, + &manifest, + &client_expected, + ) { + accepted.push(label); + } + } + + let temporary = temporary_scenario("healthy-package"); + let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let relative_path = manifest["artifacts"][2]["relativePath"] + .as_str() + .expect("provider artifact has a path") + .to_owned(); + replace_fixture_text( + &temporary.root, + &relative_path, + "ProfileId=dp-server-5.00.test-v1]LOG]!> Date: Fri, 31 Jul 2026 03:28:23 -0400 Subject: [PATCH 102/422] test(sccm): reproduce provider corpus review gaps --- ...ider_and_admin_service_fixture_contract.rs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) 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 index 86dfdbe5b..c736d0439 100644 --- 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 @@ -1382,6 +1382,16 @@ fn schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec, +) -> Vec { + let _ = records; + schema_failures(scenario, manifest, expected) +} + #[test] fn provider_and_admin_service_scenario_matrix_is_exact() { let actual = actual_scenarios().expect("provider/Admin Service fixture root exists"); @@ -2124,3 +2134,123 @@ fn generic_artifact_request_reason_fails_closed() { "generic request prose lost the exact unresolved request and terminal basis" ); } + +#[test] +fn later_uncited_same_key_terminal_failure_invalidates_high_success() { + let manifest = read_json("provider-success", "manifest.json").unwrap(); + let expected = read_json("provider-success", "expected.json").unwrap(); + let mut records = normalized_records("provider-success", &manifest); + let artifact = SccmArtifact { + artifact_id: "provider-success-current".to_owned(), + display_name: "Smsprov.log".to_owned(), + original_path: None, + host: Some("safe:server:lab-provider-01".to_owned()), + role: SccmRole::Provider, + configmgr_version: Some("5.00.TEST.0001".to_owned()), + collected_at_utc: Some("2026-07-30T21:00:00Z".to_owned()), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + }; + let raw = ""; + let mut later_failure = normalize_ccm_artifact(artifact, raw) + .into_iter() + .next() + .expect("adversarial terminal record normalizes"); + later_failure.evidence_id = "provider-success-current:6-6".to_owned(); + later_failure.reference.entry_id = "provider-success-current:6-6".to_owned(); + later_failure.reference.line_start = Some(6); + later_failure.reference.line_end = Some(6); + records.insert(("provider-success-current".to_owned(), 6, 6), later_failure); + + assert!( + !schema_failures_with_records("provider-success", &manifest, &expected, &records) + .is_empty(), + "an uncited later terminal failure for the exact transaction key retained high success" + ); +} + +#[test] +fn terminal_marker_cannot_move_from_record_outcome_to_receive() { + let manifest = read_json("provider-success", "manifest.json").unwrap(); + let mut expected = read_json("provider-success", "expected.json").unwrap(); + expected["transactions"][0]["observations"][0]["terminal"] = Value::Bool(true); + expected["transactions"][0]["observations"][4]["terminal"] = Value::Bool(false); + + assert!( + !schema_failures("provider-success", &manifest, &expected).is_empty(), + "a receive marker satisfied terminal evidence after recordOutcome became nonterminal" + ); +} + +#[test] +fn applied_collection_limit_cannot_remain_captured_or_high() { + let mut manifest = read_json("provider-success", "manifest.json").unwrap(); + let expected = read_json("provider-success", "expected.json").unwrap(); + manifest["artifacts"][0]["collectionLimit"]["limitApplied"] = Value::Bool(true); + + assert!( + !schema_failures("provider-success", &manifest, &expected).is_empty(), + "limitApplied=true retained captured coverage and high-confidence success" + ); +} + +#[test] +fn relative_path_must_match_declared_source_rotation_and_basename() { + let mut manifest = read_json("rotation-boundary", "manifest.json").unwrap(); + let expected = read_json("rotation-boundary", "expected.json").unwrap(); + let current_path = manifest["artifacts"][0]["relativePath"].clone(); + let current_bytes = manifest["artifacts"][0]["bytesCopied"].clone(); + manifest["artifacts"][0]["relativePath"] = manifest["artifacts"][1]["relativePath"].clone(); + manifest["artifacts"][0]["bytesCopied"] = manifest["artifacts"][1]["bytesCopied"].clone(); + manifest["artifacts"][1]["relativePath"] = current_path; + manifest["artifacts"][1]["bytesCopied"] = current_bytes; + + assert!( + !schema_failures("rotation-boundary", &manifest, &expected).is_empty(), + "current and lo_ artifacts accepted each other's physical paths" + ); +} + +#[test] +fn unknown_source_version_requires_canonical_public_grammar() { + let mut manifest = read_json("rotation-boundary", "manifest.json").unwrap(); + let expected = read_json("rotation-boundary", "expected.json").unwrap(); + for artifact in manifest["artifacts"].as_array_mut().unwrap() { + artifact["sourceVersion"] = Value::String("SyntheticCaller".to_owned()); + } + + assert!( + !schema_failures("rotation-boundary", &manifest, &expected).is_empty(), + "caller-shaped alphabetic text was exported as unknown source-version provenance" + ); +} + +#[test] +fn exact_rotation_topology_rejects_an_empty_endpoint() { + let mut manifest = read_json("rotation-boundary", "manifest.json").unwrap(); + let expected = read_json("rotation-boundary", "expected.json").unwrap(); + manifest["topology"]["endpoints"][0]["endpointId"] = Value::String(String::new()); + for artifact in manifest["artifacts"].as_array_mut().unwrap() { + artifact["endpointId"] = Value::String(String::new()); + } + + assert!( + !schema_failures("rotation-boundary", &manifest, &expected).is_empty(), + "an empty endpoint retained exact topology when no transaction was emitted" + ); +} + +#[test] +fn provider_retry_scenario_is_explicit() { + read_json("provider-retry", "manifest.json").expect("provider retry manifest exists"); + read_json("provider-retry", "expected.json").expect("provider retry expectation exists"); +} + +#[test] +fn contradictory_evidence_scenario_is_explicit() { + read_json("contradictory-evidence", "manifest.json") + .expect("contradictory evidence manifest exists"); + read_json("contradictory-evidence", "expected.json") + .expect("contradictory evidence expectation exists"); +} From 00896d08d5867e07ab03b32626491d77fe1f1f18 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 03:32:38 -0400 Subject: [PATCH 103/422] test(sccm): align distribution point coverage requests --- ...ver_distribution_point_fixture_contract.rs | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index 2cb962810..4ebda3987 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -1544,7 +1544,8 @@ fn validate_expected( 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" + Some(artifact.source_id.as_str()) == next_source + && artifact_has_incomplete_coverage(artifact) }) { failures.push(format!( @@ -1858,6 +1859,7 @@ fn validate_expected( let expected_requests = parsed .artifacts .values() + .filter(|artifact| artifact.role != "client") .filter_map(|artifact| { coverage_request_reason(artifact) .map(|reason| (artifact.source_id.clone(), reason.to_owned())) @@ -3285,3 +3287,71 @@ fn source_local_evidence_and_artifact_requests_are_canonical_and_unique() { "nondeterministic source-local output was accepted: {accepted:?}" ); } + +#[test] +fn client_control_coverage_does_not_create_a_server_artifact_request() { + let mut manifest = + read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); + let mut expected = + read_json("client-only-looking-request", "expected.json").expect("expected loads"); + let bytes_copied = manifest["artifacts"][0]["bytesCopied"].clone(); + manifest["artifacts"][0]["captureState"] = json!("capped"); + manifest["artifacts"][0]["collectionLimit"] = + json!({"byteLimit": bytes_copied, "limitApplied": true}); + expected["coverage"][0]["state"] = json!("capped"); + + let validation = validate_scenario_values("client-only-looking-request", &manifest, &expected); + assert!( + validation.is_ok(), + "ignored capped client control invented a server artifact request: {validation:?}" + ); +} + +#[test] +fn captured_incomplete_fragments_satisfy_the_bounded_next_source() { + let temporary = temporary_scenario("incomplete"); + let mut manifest = read_json("incomplete", "manifest.json").expect("manifest loads"); + let mut expected = read_json("incomplete", "expected.json").expect("expected loads"); + let fragments = [ + ( + 1usize, + "evidence/server-dp-distribution/site/current/PkgXferMgr.log", + "SYNTHETIC FIXTURE CURRENT FRAGMENT ONLY Date: Fri, 31 Jul 2026 03:38:39 -0400 Subject: [PATCH 104/422] test(sccm): harden provider corpus contracts --- .../provider_and_admin_service/README.md | 10 + .../provider-local/current/Smsprov.log | 6 + .../contradictory-evidence/expected.json | 130 +++++++++ .../contradictory-evidence/manifest.json | 54 ++++ .../provider-local/current/Smsprov.log | 6 + .../provider-retry/expected.json | 125 ++++++++ .../provider-retry/manifest.json | 54 ++++ ...ider_and_admin_service_fixture_contract.rs | 273 ++++++++++++++++-- ...issue-332-provider-admin-service-corpus.md | 17 +- 9 files changed, 648 insertions(+), 27 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/evidence/server-provider/provider-local/current/Smsprov.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/evidence/server-provider/provider-local/current/Smsprov.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/manifest.json 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 index 8d3cbd488..e9cc019e5 100644 --- 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 @@ -28,7 +28,9 @@ cannot invent a role, endpoint, or installed component. | `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 | +| `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 | @@ -45,6 +47,14 @@ cannot invent a role, endpoint, or installed component. 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 diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/evidence/server-provider/provider-local/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/evidence/server-provider/provider-local/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/server-provider/provider-local/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..2b5b97694 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/expected.json @@ -0,0 +1,130 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "providerAndAdminService", + "scenario": "contradictory-evidence", + "profiles": [ + { + "layer": "provider", + "selectionState": "selectedSynthetic", + "profileId": "provider-server-5.00.test-v1" + } + ], + "coverage": [ + { + "artifactId": "contradictory-provider-current", + "state": "captured", + "sourceId": "server-provider", + "layer": "provider" + } + ], + "transactions": [ + { + "transactionId": "provider:dddddddd-dddd-dddd-dddd-dddddddddddd:safe-operation-contradictory:provider-local", + "layer": "provider", + "key": { + "requestId": "dddddddd-dddd-dddd-dddd-dddddddddddd", + "operationHandle": "safe-operation-contradictory", + "endpointId": "provider-local", + "confidence": "exact", + "extractionProfileId": "provider-server-5.00.test-v1" + }, + "topologyCompatibility": "exact", + "timestampOrdering": "usable", + "state": "incomplete", + "classification": "insufficientEvidence", + "confidence": "low", + "confidenceCeiling": "low", + "terminalEvidence": true, + "coverageGapArtifactIds": [], + "publicSummary": "Provider evidence contains contradictory terminal outcomes for one exact request key.", + "observations": [ + { + "observationId": "contradictory-01-receive", + "phase": "receive", + "disposition": "succeeded", + "terminal": false, + "evidence": [ + { + "artifactId": "contradictory-provider-current", + "startLine": 1, + "endLine": 1 + } + ] + }, + { + "observationId": "contradictory-02-authorize", + "phase": "authenticateOrAuthorize", + "disposition": "succeeded", + "terminal": false, + "evidence": [ + { + "artifactId": "contradictory-provider-current", + "startLine": 2, + "endLine": 2 + } + ] + }, + { + "observationId": "contradictory-03-execute", + "phase": "executeProviderOperation", + "disposition": "succeeded", + "terminal": false, + "evidence": [ + { + "artifactId": "contradictory-provider-current", + "startLine": 3, + "endLine": 3 + } + ] + }, + { + "observationId": "contradictory-04-respond", + "phase": "respond", + "disposition": "succeeded", + "terminal": false, + "evidence": [ + { + "artifactId": "contradictory-provider-current", + "startLine": 4, + "endLine": 4 + } + ] + }, + { + "observationId": "contradictory-05-success", + "phase": "recordOutcome", + "disposition": "succeeded", + "terminal": true, + "evidence": [ + { + "artifactId": "contradictory-provider-current", + "startLine": 5, + "endLine": 5 + } + ] + }, + { + "observationId": "contradictory-06-failure", + "phase": "recordOutcome", + "disposition": "failed", + "terminal": true, + "evidence": [ + { + "artifactId": "contradictory-provider-current", + "startLine": 6, + "endLine": 6 + } + ] + } + ] + } + ], + "sourceLocalObservations": [], + "artifactRequests": [ + { + "logicalArtifactId": "server-provider", + "reason": "Capture the bounded Provider source lineage for the exact request key and reconcile contradictory terminal outcomes." + } + ], + "crossSideCausalClaims": [] +} 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..c81a7f624 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/manifest.json @@ -0,0 +1,54 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "contradictory-evidence", + "bundle": { + "bundleRole": "server", + "workflow": "providerAndAdminService", + "capturedUtc": "2026-07-30T23:10:00Z" + }, + "topology": { + "siteCode": "LAB", + "rolesObserved": [ + "provider" + ], + "endpoints": [ + { + "endpointId": "provider-local", + "layer": "provider", + "hostHandle": "safe:server:lab-provider-01", + "producerRole": "provider" + } + ] + }, + "artifacts": [ + { + "artifactId": "contradictory-provider-current", + "sourceId": "server-provider", + "producerRole": "provider", + "layer": "provider", + "producerHostHandle": "safe:server:lab-provider-01", + "endpointId": "provider-local", + "diagnosticUse": "primary", + "originalBasename": "Smsprov.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/Smsprov.log", + "pathFingerprint": "synthetic:contradictory-provider-current", + "rotation": { + "kind": "current", + "lineageId": "contradictory-provider", + "fragmentComplete": true + }, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T23:10:00Z", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "bytesCopied": 2416, + "relativePath": "evidence/server-provider/provider-local/current/Smsprov.log" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/evidence/server-provider/provider-local/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/evidence/server-provider/provider-local/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/server-provider/provider-local/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..a1577954d --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/expected.json @@ -0,0 +1,125 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "providerAndAdminService", + "scenario": "provider-retry", + "profiles": [ + { + "layer": "provider", + "selectionState": "selectedSynthetic", + "profileId": "provider-server-5.00.test-v1" + } + ], + "coverage": [ + { + "artifactId": "provider-retry-current", + "state": "captured", + "sourceId": "server-provider", + "layer": "provider" + } + ], + "transactions": [ + { + "transactionId": "provider:cccccccc-cccc-cccc-cccc-cccccccccccc:safe-operation-provider-retry:provider-local", + "layer": "provider", + "key": { + "requestId": "cccccccc-cccc-cccc-cccc-cccccccccccc", + "operationHandle": "safe-operation-provider-retry", + "endpointId": "provider-local", + "confidence": "exact", + "extractionProfileId": "provider-server-5.00.test-v1" + }, + "topologyCompatibility": "exact", + "timestampOrdering": "usable", + "state": "succeeded", + "classification": "success", + "confidence": "high", + "confidenceCeiling": "high", + "terminalEvidence": true, + "coverageGapArtifactIds": [], + "publicSummary": "Provider retry completed with explicit terminal recovery evidence.", + "observations": [ + { + "observationId": "provider-retry-01-receive", + "phase": "receive", + "disposition": "succeeded", + "terminal": false, + "evidence": [ + { + "artifactId": "provider-retry-current", + "startLine": 1, + "endLine": 1 + } + ] + }, + { + "observationId": "provider-retry-02-authorize", + "phase": "authenticateOrAuthorize", + "disposition": "succeeded", + "terminal": false, + "evidence": [ + { + "artifactId": "provider-retry-current", + "startLine": 2, + "endLine": 2 + } + ] + }, + { + "observationId": "provider-retry-03-retryable", + "phase": "executeProviderOperation", + "disposition": "retryableFailure", + "terminal": false, + "evidence": [ + { + "artifactId": "provider-retry-current", + "startLine": 3, + "endLine": 3 + } + ] + }, + { + "observationId": "provider-retry-04-recovered", + "phase": "executeProviderOperation", + "disposition": "succeeded", + "terminal": false, + "evidence": [ + { + "artifactId": "provider-retry-current", + "startLine": 4, + "endLine": 4 + } + ] + }, + { + "observationId": "provider-retry-05-respond", + "phase": "respond", + "disposition": "succeeded", + "terminal": false, + "evidence": [ + { + "artifactId": "provider-retry-current", + "startLine": 5, + "endLine": 5 + } + ] + }, + { + "observationId": "provider-retry-06-outcome", + "phase": "recordOutcome", + "disposition": "succeeded", + "terminal": true, + "evidence": [ + { + "artifactId": "provider-retry-current", + "startLine": 6, + "endLine": 6 + } + ] + } + ] + } + ], + "sourceLocalObservations": [], + "artifactRequests": [], + "crossSideCausalClaims": [] +} 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..5d9c59190 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/manifest.json @@ -0,0 +1,54 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "provider-retry", + "bundle": { + "bundleRole": "server", + "workflow": "providerAndAdminService", + "capturedUtc": "2026-07-30T23:00:00Z" + }, + "topology": { + "siteCode": "LAB", + "rolesObserved": [ + "provider" + ], + "endpoints": [ + { + "endpointId": "provider-local", + "layer": "provider", + "hostHandle": "safe:server:lab-provider-01", + "producerRole": "provider" + } + ] + }, + "artifacts": [ + { + "artifactId": "provider-retry-current", + "sourceId": "server-provider", + "producerRole": "provider", + "layer": "provider", + "producerHostHandle": "safe:server:lab-provider-01", + "endpointId": "provider-local", + "diagnosticUse": "primary", + "originalBasename": "Smsprov.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/Smsprov.log", + "pathFingerprint": "synthetic:provider-retry-current", + "rotation": { + "kind": "current", + "lineageId": "provider-retry", + "fragmentComplete": true + }, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T23:00:00Z", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "bytesCopied": 2444, + "relativePath": "evidence/server-provider/provider-local/current/Smsprov.log" + } + ] +} 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 index c736d0439..96619cda6 100644 --- 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 @@ -9,15 +9,17 @@ use cmtraceopen_parser::sccm::{ }; use serde_json::Value; -const SCENARIOS: [&str; 11] = [ +const SCENARIOS: [&str; 13] = [ "admin-service-auth-failure", "admin-service-backend-failure", "admin-service-success", + "contradictory-evidence", "iis-supplemental", "incomplete", "privacy-redaction", "provider-authz-denied", "provider-query-failure", + "provider-retry", "provider-success", "provider-timeout", "rotation-boundary", @@ -105,6 +107,16 @@ fn safe_synthetic_lineage(value: &str) -> bool { .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') } +fn safe_endpoint_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && !value.starts_with('-') + && !value.ends_with('-') + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + fn expected_sanitized_source_path( source_id: Option<&str>, rotation: &Value, @@ -126,6 +138,29 @@ fn expected_sanitized_source_path( } } +fn expected_relative_path( + source_id: Option<&str>, + endpoint_id: Option<&str>, + rotation: &Value, +) -> Option { + let endpoint_id = endpoint_id.filter(|value| safe_endpoint_id(value))?; + match (source_id, rotation["kind"].as_str()) { + (Some("server-provider"), Some("current")) => Some(format!( + "evidence/server-provider/{endpoint_id}/current/Smsprov.log" + )), + (Some("server-provider"), Some("lo_")) => Some(format!( + "evidence/server-provider/{endpoint_id}/lo_/Smsprov.lo_" + )), + (Some("server-admin-service"), Some("current")) => Some(format!( + "evidence/server-admin-service/{endpoint_id}/current/AdminService.log" + )), + (Some("server-admin-service-iis"), Some("current")) => Some(format!( + "evidence/server-admin-service-iis/{endpoint_id}/current/u_ex_synthetic.log" + )), + _ => None, + } +} + fn exported_projection_contains_private_shape(value: &Value) -> bool { match value { Value::String(value) => { @@ -314,6 +349,9 @@ fn expected_transaction_ids(scenario: &str) -> &'static [&'static str] { "admin-service-success" => &[ "adminService:55555555-5555-5555-5555-555555555555:safe-operation-admin-read:admin-service-lab", ], + "contradictory-evidence" => &[ + "provider:dddddddd-dddd-dddd-dddd-dddddddddddd:safe-operation-contradictory:provider-local", + ], "iis-supplemental" => &[ "adminService:88888888-8888-8888-8888-888888888888:safe-operation-admin-iis:admin-service-lab", ], @@ -330,6 +368,9 @@ fn expected_transaction_ids(scenario: &str) -> &'static [&'static str] { "provider-query-failure" => &[ "provider:33333333-3333-3333-3333-333333333333:safe-operation-query-device:provider-local", ], + "provider-retry" => &[ + "provider:cccccccc-cccc-cccc-cccc-cccccccccccc:safe-operation-provider-retry:provider-local", + ], "provider-success" => &[ "provider:11111111-1111-1111-1111-111111111111:safe-operation-read-device:provider-local", ], @@ -351,6 +392,8 @@ fn expected_outcomes( "admin-service-success" | "iis-supplemental" | "provider-success" => { &[("succeeded", "success", "high", "high")] } + "provider-retry" => &[("succeeded", "success", "high", "high")], + "contradictory-evidence" => &[("incomplete", "insufficientEvidence", "low", "low")], "privacy-redaction" => &[ ("succeeded", "success", "high", "high"), ("succeeded", "success", "high", "high"), @@ -375,6 +418,9 @@ fn expected_public_summaries(scenario: &str) -> &'static [&'static str] { "admin-service-success" => { &["Admin Service request completed with explicit terminal evidence."] } + "contradictory-evidence" => &[ + "Provider evidence contains contradictory terminal outcomes for one exact request key.", + ], "iis-supplemental" => &["Admin Service evidence independently records a terminal success."], "incomplete" => { &["Admin Service evidence stops before an explicit response or terminal outcome."] @@ -385,6 +431,7 @@ fn expected_public_summaries(scenario: &str) -> &'static [&'static str] { ], "provider-authz-denied" => &["Provider authorization was explicitly denied."], "provider-query-failure" => &["Provider operation recorded an explicit terminal failure."], + "provider-retry" => &["Provider retry completed with explicit terminal recovery evidence."], "provider-success" => &["Provider operation completed with explicit terminal evidence."], "provider-timeout" => &["Provider evidence stops before an explicit terminal outcome."], "rotation-boundary" => &[], @@ -397,11 +444,13 @@ fn expected_artifact_ids(scenario: &str) -> &'static [&'static str] { "admin-service-auth-failure" => &["admin-auth-current"], "admin-service-backend-failure" => &["admin-backend-current"], "admin-service-success" => &["admin-success-current"], + "contradictory-evidence" => &["contradictory-provider-current"], "iis-supplemental" => &["admin-iis-current", "iis-supplemental-current"], "incomplete" => &["incomplete-admin-current"], "privacy-redaction" => &["privacy-admin-current", "privacy-provider-current"], "provider-authz-denied" => &["provider-authz-current"], "provider-query-failure" => &["provider-query-current"], + "provider-retry" => &["provider-retry-current"], "provider-success" => &["provider-success-current"], "provider-timeout" => &["provider-timeout-current"], "rotation-boundary" => &["rotation-01-current", "rotation-02-lo"], @@ -431,6 +480,14 @@ fn expected_observation_ids(scenario: &str) -> &'static [&'static str] { "admin-success-05-respond", "admin-success-06-outcome", ], + "contradictory-evidence" => &[ + "contradictory-01-receive", + "contradictory-02-authorize", + "contradictory-03-execute", + "contradictory-04-respond", + "contradictory-05-success", + "contradictory-06-failure", + ], "iis-supplemental" => &[ "admin-iis-01-receive", "admin-iis-02-route", @@ -455,6 +512,14 @@ fn expected_observation_ids(scenario: &str) -> &'static [&'static str] { "provider-query-03-execute", "provider-query-04-outcome", ], + "provider-retry" => &[ + "provider-retry-01-receive", + "provider-retry-02-authorize", + "provider-retry-03-retryable", + "provider-retry-04-recovered", + "provider-retry-05-respond", + "provider-retry-06-outcome", + ], "provider-success" => &[ "provider-success-01-receive", "provider-success-02-authorize", @@ -507,6 +572,10 @@ fn expected_artifact_requests(scenario: &str) -> &'static [(&'static str, &'stat "server-provider", "Capture the bounded Provider source lineage for the exact request key and terminal outcome.", )], + "contradictory-evidence" => &[( + "server-provider", + "Capture the bounded Provider source lineage for the exact request key and reconcile contradictory terminal outcomes.", + )], "rotation-boundary" => &[( "server-provider", "Capture one bounded complete Provider rotation with known version provenance.", @@ -524,7 +593,9 @@ fn expected_profile_layers(scenario: &str) -> &'static [&'static str] { | "incomplete" => &["adminService"], "privacy-redaction" => &["adminService", "provider"], "provider-authz-denied" + | "contradictory-evidence" | "provider-query-failure" + | "provider-retry" | "provider-success" | "provider-timeout" | "rotation-boundary" => &["provider"], @@ -539,11 +610,23 @@ fn known_test_version(value: &str) -> bool { } fn safe_version(value: &str) -> bool { - !value.is_empty() - && value.len() <= 64 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'.') + if value.is_empty() || value.len() > 64 { + return false; + } + let segments = value.split('.').collect::>(); + let digits = + |segment: &str| !segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_digit()); + match segments.as_slice() { + ["IIS", "TEST", suffix] => suffix.len() == 4 && digits(suffix), + [major, minor, "UNKNOWN"] => digits(major) && digits(minor), + [major, minor, "TEST", suffix] => { + digits(major) && digits(minor) && suffix.len() == 4 && digits(suffix) + } + [major, minor, build, revision] => { + digits(major) && digits(minor) && digits(build) && digits(revision) + } + _ => false, + } } fn state_chain(layer: &str) -> Option<&'static [&'static str]> { @@ -629,7 +712,8 @@ fn schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec Vec { let relative_path = artifact["relativePath"].as_str(); + let byte_limit = artifact["collectionLimit"]["byteLimit"].as_u64(); + let limit_applied = artifact["collectionLimit"]["limitApplied"].as_bool(); + let bytes_copied = artifact["bytesCopied"].as_u64(); + let limit_matches_state = match state { + Some("captured") => limit_applied == Some(false), + Some("capped") => { + limit_applied == Some(true) + && bytes_copied.is_some() + && bytes_copied == byte_limit + } + Some("parseFailed") => limit_applied == Some(false), + _ => false, + }; if relative_path.is_none_or(|value| !safe_segmented_path(value, "evidence/")) + || relative_path + != expected_relative_path(source_id, endpoint_id, &artifact["rotation"]) + .as_deref() || relative_path .map(str::to_ascii_lowercase) .is_none_or(|value| !destinations.insert(value)) - || artifact["bytesCopied"].as_u64().is_none() + || bytes_copied.is_none() || artifact["encoding"] != "utf-8" - || artifact["collectionLimit"]["byteLimit"].as_u64().is_none() - || artifact["collectionLimit"]["limitApplied"] - .as_bool() - .is_none() + || byte_limit.is_none_or(|limit| limit == 0) + || bytes_copied + .zip(byte_limit) + .is_none_or(|(copied, limit)| copied > limit) + || !limit_matches_state || artifact["rotation"]["fragmentComplete"].as_bool().is_none() || artifact["sourceVersion"] .as_str() @@ -1125,6 +1226,8 @@ fn schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec Vec Vec { + terminal_dispositions == BTreeSet::from(["succeeded"]) + && transaction["classification"] == "success" + } + Some("failed") => { + terminal_dispositions == BTreeSet::from(["failed"]) + && transaction["classification"] == "confirmedFailure" + } + Some("incomplete") if cited_terminal => { + terminal_dispositions.len() > 1 + && transaction["classification"] == "insufficientEvidence" + } + Some("incomplete") => { + terminal_dispositions.is_empty() + && transaction["classification"] == "insufficientEvidence" + } + _ => false, + }; + if !terminal_outcome_matches { + failures.push( + "terminal outcome disposition does not agree with conservative state/classification" + .to_owned(), + ); + } + if scenario == "provider-retry" { + let retry_sequence = phase_dispositions + .iter() + .filter_map(|(phase, disposition)| { + (*phase == "executeProviderOperation").then_some(*disposition) + }) + .collect::>(); + if retry_sequence != ["retryableFailure", "succeeded"] { + failures.push( + "provider retry lacks one explicit retryable failure followed by recovery" + .to_owned(), + ); + } + } } let mut sorted_transaction_ids = transaction_ids.clone(); sorted_transaction_ids.sort_unstable(); @@ -1388,8 +1545,58 @@ fn schema_failures_with_records( expected: &Value, records: &BTreeMap<(String, u32, u32), SccmEvidence>, ) -> Vec { - let _ = records; - schema_failures(scenario, manifest, expected) + let mut failures = schema_failures(scenario, manifest, expected); + failures.extend(uncited_transaction_record_failures(expected, records)); + failures +} + +fn uncited_transaction_record_failures( + expected: &Value, + records: &BTreeMap<(String, u32, u32), SccmEvidence>, +) -> Vec { + let mut failures = Vec::new(); + for transaction in expected["transactions"].as_array().into_iter().flatten() { + let layer = transaction["layer"].as_str(); + let key = &transaction["key"]; + let cited = transaction["observations"] + .as_array() + .into_iter() + .flatten() + .flat_map(|observation| observation["evidence"].as_array().into_iter().flatten()) + .filter_map(|reference| { + Some(( + reference["artifactId"].as_str()?.to_owned(), + u32::try_from(reference["startLine"].as_u64()?).ok()?, + u32::try_from(reference["endLine"].as_u64()?).ok()?, + )) + }) + .collect::>(); + + for (record_key, record) in records { + let Ok(fields) = parse_fixture_fields(&record.message) else { + continue; + }; + let matches_exact_key = [ + ("RequestId", key["requestId"].as_str()), + ("OperationHandle", key["operationHandle"].as_str()), + ("EndpointId", key["endpointId"].as_str()), + ("Layer", layer), + ("ProfileId", key["extractionProfileId"].as_str()), + ] + .iter() + .all(|(field, value)| fields.get(*field).map(String::as_str) == *value); + if matches_exact_key && !cited.contains(record_key) { + failures.push(format!( + "{}: admitted exact-key logical record {}:{}-{} is uncited", + transaction["transactionId"].as_str().unwrap_or(""), + record_key.0, + record_key.1, + record_key.2 + )); + } + } + } + failures } #[test] @@ -1666,6 +1873,10 @@ fn request_transactions_are_exact_cited_ordered_and_layer_local() { } } + for failure in uncited_transaction_record_failures(&expected, &records) { + failures.push(format!("{scenario}: {failure}")); + } + if scenario == "rotation-boundary" && !records.is_empty() { failures.push( "rotation-boundary: partial rotation fragments formed logical evidence".to_owned(), @@ -2214,16 +2425,19 @@ fn relative_path_must_match_declared_source_rotation_and_basename() { #[test] fn unknown_source_version_requires_canonical_public_grammar() { - let mut manifest = read_json("rotation-boundary", "manifest.json").unwrap(); + let manifest = read_json("rotation-boundary", "manifest.json").unwrap(); let expected = read_json("rotation-boundary", "expected.json").unwrap(); - for artifact in manifest["artifacts"].as_array_mut().unwrap() { - artifact["sourceVersion"] = Value::String("SyntheticCaller".to_owned()); - } - assert!( - !schema_failures("rotation-boundary", &manifest, &expected).is_empty(), - "caller-shaped alphabetic text was exported as unknown source-version provenance" - ); + for unsafe_version in ["SyntheticCaller", "1.2.SYNTHETICCALLER"] { + let mut mutated = manifest.clone(); + for artifact in mutated["artifacts"].as_array_mut().unwrap() { + artifact["sourceVersion"] = Value::String(unsafe_version.to_owned()); + } + assert!( + !schema_failures("rotation-boundary", &mutated, &expected).is_empty(), + "caller-shaped text {unsafe_version} was exported as unknown source-version provenance" + ); + } } #[test] @@ -2241,6 +2455,19 @@ fn exact_rotation_topology_rejects_an_empty_endpoint() { ); } +#[test] +fn provider_retry_requires_an_explicit_retryable_failure_then_recovery() { + let manifest = read_json("provider-retry", "manifest.json").unwrap(); + let mut expected = read_json("provider-retry", "expected.json").unwrap(); + expected["transactions"][0]["observations"][2]["disposition"] = + Value::String("succeeded".to_owned()); + + assert!( + !schema_failures("provider-retry", &manifest, &expected).is_empty(), + "a nominal success sequence replaced the explicit retryable failure" + ); +} + #[test] fn provider_retry_scenario_is_explicit() { read_json("provider-retry", "manifest.json").expect("provider retry manifest exists"); diff --git a/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md b/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md index 0e7dff83e..dd061b524 100644 --- a/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md +++ b/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md @@ -2,10 +2,11 @@ ## Status -This slice prepares the source and reducer contracts only. Production -extraction/reduction waits for reviewed, stable #318 and #335 interfaces. No -Windows collection, network call, SQL/WMI query, database access, Tauri -command, or live SCCM acceptance is included. +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 @@ -95,6 +96,14 @@ 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. + ## Explicit limits - This is not a production reducer. From a8e93a2607d5ffed7bde989e80476e7ce0541084 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 04:38:00 -0400 Subject: [PATCH 105/422] test(sccm): reproduce hierarchy review gaps --- ...rarchy_and_replication_fixture_contract.rs | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) 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 index cff3f9616..7a87afb0e 100644 --- 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 @@ -2633,3 +2633,184 @@ fn hierarchy_review_4826191775_mutations_fail_closed() { "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 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 candidate_acceptances = Vec::new(); + + 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["artifacts"] + .as_array() + .expect("artifacts are an array"), + ) + .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["artifacts"] + .as_array() + .expect("artifacts are an array"), + ) + .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, + ); + drop(audit); + accepted.extend(candidate_acceptances); + + assert!( + accepted.is_empty(), + "review 4826454819 mutations were accepted: {accepted:?}" + ); +} From 26098eb440feedf080f0abeef2d8ec76678a2599 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 04:40:55 -0400 Subject: [PATCH 106/422] test(sccm): reproduce provider privacy and disposition gaps --- ...ider_and_admin_service_fixture_contract.rs | 134 ++++++++++++++++++ .../tests/sccm_spine_contract.rs | 76 ++++++++++ 2 files changed, 210 insertions(+) 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 index 96619cda6..ce74007b6 100644 --- 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 @@ -2455,6 +2455,140 @@ fn exact_rotation_topology_rejects_an_empty_endpoint() { ); } +#[test] +fn provider_sensitive_fields_never_enter_public_or_candidate_projections() { + const REDACTED: &str = "[redacted:sccm-public-message-v1]"; + let prefix = "[sccm-public-message-v1] "; + let core = "Phase=receive; Disposition=succeeded; Terminal=false; RequestId=11111111-1111-1111-1111-111111111111; OperationHandle=safe-operation-read-device; EndpointId=provider-local; Layer=provider; ProfileId=provider-server-5.00.test-v1"; + let raw_fields = [ + ( + "CallerHandle=opaque-private-caller", + "opaque-private-caller", + ), + ( + "CallerHandle=private-caller-middle", + "private-caller-middle", + ), + ("CallerHandle=private-caller-tail", "private-caller-tail"), + ( + "QueryHandle=/AdminService/v1.0/device", + "/AdminService/v1.0/device", + ), + ( + "QueryHandle=SELECT * FROM SMS_R_System", + "SELECT * FROM SMS_R_System", + ), + ("QueryHandle=opaque-private-query", "opaque-private-query"), + ( + "Authorization=Bearer private-auth-start", + "private-auth-start", + ), + ( + "Authorization=Custom private-auth-middle", + "private-auth-middle", + ), + ("Authorization=Basic private-auth-tail", "private-auth-tail"), + ]; + + for (index, (raw_field, sensitive)) in raw_fields.into_iter().enumerate() { + let message = match index % 3 { + 0 => format!("{prefix}SYNTHETIC FIXTURE; {raw_field}; {core}"), + 1 => format!( + "{prefix}SYNTHETIC FIXTURE; Phase=receive; {raw_field}; Disposition=succeeded; Terminal=false; RequestId=11111111-1111-1111-1111-111111111111; OperationHandle=safe-operation-read-device; EndpointId=provider-local; Layer=provider; ProfileId=provider-server-5.00.test-v1" + ), + _ => format!("{prefix}SYNTHETIC FIXTURE; {core}; {raw_field}"), + }; + let error = parse_fixture_fields(&message) + .expect_err("a raw sensitive fixture field must fail closed"); + assert!( + !error.contains(sensitive), + "candidate error leaked {sensitive}" + ); + } + + for field in ["CallerHandle", "QueryHandle", "Authorization"] { + let message = format!("{prefix}SYNTHETIC FIXTURE; {core}; {field}={REDACTED}"); + let fields = parse_fixture_fields(&message) + .unwrap_or_else(|error| panic!("{field} redaction marker was rejected: {error}")); + assert!( + !fields.contains_key(field), + "{field} entered exact facts after redaction" + ); + } + + let manifest = read_json("privacy-redaction", "manifest.json").unwrap(); + let records = normalized_records("privacy-redaction", &manifest); + let public_json = serde_json::to_string(&records.values().collect::>()).unwrap(); + for sensitive in [ + "synthetic.user@example.invalid", + "SYNTHETIC-RAW-BEARER-DO-NOT-EXPORT", + "SELECT * FROM SMS_R_System", + "/AdminService/v1.0/device", + ] { + assert!( + !public_json.contains(sensitive), + "public evidence leaked {sensitive}" + ); + } + for record in records.values() { + let fields = parse_fixture_fields(&record.message) + .unwrap_or_else(|error| panic!("projected fixture field is invalid: {error}")); + for field in ["CallerHandle", "QueryHandle", "Authorization"] { + assert!( + !fields.contains_key(field), + "{field} entered an exact-key correlation candidate" + ); + } + } +} + +#[test] +fn nonterminal_disposition_and_recovery_vocabulary_is_closed() { + let manifest = read_json("provider-success", "manifest.json").unwrap(); + let expected = read_json("provider-success", "expected.json").unwrap(); + let records = normalized_records("provider-success", &manifest); + let mut accepted = Vec::new(); + + for disposition in ["manufacturedRecovery", "failed", "retryableFailure"] { + let mut paired_expected = expected.clone(); + let observation = &mut paired_expected["transactions"][0]["observations"][2]; + observation["disposition"] = Value::String(disposition.to_owned()); + let artifact_id = observation["evidence"][0]["artifactId"] + .as_str() + .unwrap() + .to_owned(); + let start_line = + u32::try_from(observation["evidence"][0]["startLine"].as_u64().unwrap()).unwrap(); + let end_line = + u32::try_from(observation["evidence"][0]["endLine"].as_u64().unwrap()).unwrap(); + let mut paired_records = records.clone(); + let record = paired_records + .get_mut(&(artifact_id, start_line, end_line)) + .expect("paired evidence record exists"); + record.message = record.message.replacen( + "Disposition=succeeded", + &format!("Disposition={disposition}"), + 1, + ); + + if schema_failures_with_records( + "provider-success", + &manifest, + &paired_expected, + &paired_records, + ) + .is_empty() + { + accepted.push(disposition); + } + } + + assert!( + accepted.is_empty(), + "paired high-success mutations accepted nonterminal dispositions: {accepted:#?}" + ); +} + #[test] fn provider_retry_requires_an_explicit_retryable_failure_then_recovery() { let manifest = read_json("provider-retry", "manifest.json").unwrap(); diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 0054e8fbe..0ec1d6503 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -4599,6 +4599,82 @@ fn evidence_public_message_projection_redacts_sensitive_markers_and_preserves_sa 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, + ), + ]; + + 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}"; From 91e4c07bef53ea4e7a5b44cadddb17a02231a25d Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 04:43:40 -0400 Subject: [PATCH 107/422] fix(sccm): close provider privacy and disposition contracts --- .../cmtraceopen-parser/src/sccm/evidence.rs | 33 +++++++++++++++- ...ider_and_admin_service_fixture_contract.rs | 38 ++++++++++++++++--- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/evidence.rs b/crates/cmtraceopen-parser/src/sccm/evidence.rs index 63fd75145..71b8e46e0 100644 --- a/crates/cmtraceopen-parser/src/sccm/evidence.rs +++ b/crates/cmtraceopen-parser/src/sccm/evidence.rs @@ -30,8 +30,10 @@ fn sensitive_message_label_re() -> &'static Regex { | account[\x20_-]?key | samaccountname | accountname + | callerhandle | localuser | identity + | queryhandle | user[\x20_-]?principal[\x20_-]?name | credential | password @@ -107,7 +109,11 @@ fn redact_sensitive_segments(value: &str) -> String { projected.push_str(&value[copied_through..label.start()]); projected.push_str(PUBLIC_MESSAGE_REDACTION); - let value_end = sensitive_value_end(value, label.end()); + 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; } @@ -116,6 +122,31 @@ fn redact_sensitive_segments(value: &str) -> String { 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); + } + + remaining + .char_indices() + .find_map(|(offset, character)| { + matches!(character, ';' | '\r' | '\n').then_some(value_start + offset) + }) + .unwrap_or(value.len()) +} + fn sensitive_value_end(value: &str, value_start: usize) -> usize { let remaining = &value[value_start..]; let Some(first) = remaining.chars().next() else { 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 index ce74007b6..3b54702ee 100644 --- 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 @@ -270,6 +270,12 @@ fn parse_fixture_fields(message: &str) -> Result, Strin if !allowed.contains(&name) || value.is_empty() { return Err(format!("unsupported or empty fixture field {name}")); } + if matches!(name, "CallerHandle" | "QueryHandle" | "Authorization") { + if value != "[redacted:sccm-public-message-v1]" { + return Err(format!("raw sensitive fixture field {name}")); + } + continue; + } if fields.insert(name.to_owned(), value.to_owned()).is_some() { return Err(format!("duplicate fixture field {name}")); } @@ -1244,14 +1250,16 @@ fn schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec Vec Vec>(); From 5ead896ba9db54d64f8e1485235dde37ff450881 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 05:11:00 -0400 Subject: [PATCH 108/422] test(sccm): close hierarchy review gaps --- ...rarchy_and_replication_fixture_contract.rs | 856 +++++++++++++----- 1 file changed, 637 insertions(+), 219 deletions(-) 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 index 7a87afb0e..ee7575020 100644 --- 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 @@ -162,60 +162,196 @@ fn parse_fixture_fields(message: &str) -> Result, Strin 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 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() == target_host_for_site(manifest, target_site) + } + _ => false, + } +} + +fn artifact_is_exact_candidate(manifest: &Value, artifact: &Value) -> bool { + artifact["captureState"] == "captured" + && artifact["sourceVersion"] == EXACT_SOURCE_VERSION + && 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["relativePath"] + .as_str() + .is_some_and(|value| safe_segmented_path(value, "evidence/")) + && artifact["pathFingerprint"].as_str().is_some_and(|value| { + value + .strip_prefix("synthetic:") + .is_some_and(|suffix| !suffix.is_empty()) + }) + && rotation(&artifact["rotation"]).is_some() + && artifact["rotation"]["lineageId"] + .as_str() + .is_some_and(|value| !value.is_empty()) + && 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(); - for artifact in manifest["artifacts"] + let artifacts = manifest["artifacts"] .as_array() - .expect("manifest artifacts are an array") - { - let state = artifact["captureState"].as_str().unwrap_or_default(); + .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 = artifact["artifactId"] - .as_str() - .expect("artifact ID is a string"); - let relative_path = artifact["relativePath"] - .as_str() - .expect("physical artifact has a relative path"); + 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)) - .expect("fixture evidence is readable UTF-8"); + .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() - .expect("artifact basename is a string") + .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"]).expect("rotation is valid"), - coverage: coverage_state(state).expect("coverage is valid"), - encoding: Some("utf-8".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 - .expect("normalized evidence has a start line"); - let line_end = record - .reference - .line_end - .expect("normalized evidence has an end line"); - assert!( - records - .insert((artifact_id.to_owned(), line_start, line_end), record) - .is_none(), - "{scenario}: duplicate physical logical evidence" - ); + 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" + )); + } } } - records + Ok(records) } #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Serialize)] @@ -254,15 +390,18 @@ struct HierarchyCandidateGroup { fn hierarchy_candidate_groups( scenario: &str, - artifacts: &[Value], + 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 { - let state = required_string(artifact, "captureState", scenario)?; - if !matches!(state, "captured" | "capped") { + 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)?; @@ -298,6 +437,9 @@ fn hierarchy_candidate_groups( 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; }; @@ -325,7 +467,10 @@ fn hierarchy_candidate_groups( let Some(terminal) = fields.get("Terminal") else { continue; }; - if extraction_profile_id != EXACT_PROFILE || !phase_is_owned_by(basename, phase) { + 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() { @@ -373,8 +518,8 @@ fn hierarchy_candidate_groups( .collect()) } -fn hierarchy_candidate_bytes(scenario: &str, artifacts: &[Value]) -> Result, String> { - serde_json::to_vec(&hierarchy_candidate_groups(scenario, artifacts)?) +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}")) } @@ -388,6 +533,92 @@ fn phase_is_owned_by(basename: &str, phase: &str) -> bool { ) } +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)) +} + +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) -> 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 !matches!(disposition, "succeeded" | "failed" | "retrying") { + 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 (state, classification) = if transaction["timestampOrdering"] != "usable" { + ("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 expected_transaction_ids(scenario: &str) -> &'static [&'static str] { match scenario { "absent-remote-source" => &["hierarchy:msg-absent-01:LAB:CHD:link-lab-chd"], @@ -465,7 +696,7 @@ fn declared_target_site_codes(manifest: &Value) -> BTreeSet<&str> { .collect() } -#[derive(Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] struct ArtifactRequestBasis { source_id: String, direction: String, @@ -473,6 +704,12 @@ struct ArtifactRequestBasis { 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()), @@ -660,6 +897,46 @@ fn invalid_offset_request_basis(scenario: &str, manifest: &Value) -> Option 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(), + }); + } + } + } + if let Some(basis) = invalid_offset_request_basis(scenario, manifest) { + requests.insert(ArtifactRequestContract { + basis, + reason_code: "invalidOffset".to_owned(), + }); + } + 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 { @@ -756,6 +1033,18 @@ fn artifact_request_failures(scenario: &str, manifest: &Value, expected: &Value) } } + let declared_requests = requests + .iter() + .filter_map(declared_artifact_request) + .collect::>(); + if declared_requests.len() != requests.len() + || declared_requests != derived_artifact_requests(scenario, manifest) + { + failures.push(format!( + "{scenario}: artifact requests are not the complete derived bounded set" + )); + } + failures } @@ -788,6 +1077,18 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val ) { 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(); @@ -891,6 +1192,7 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val } if artifact["producerRole"] != "siteServer" || !matches!(artifact["direction"].as_str(), Some("origin" | "target")) + || !artifact_has_exact_source_tuple(artifact) || artifact["sourceVersion"].as_str() != Some(EXACT_SOURCE_VERSION) || artifact["collectedUtc"] .as_str() @@ -1100,6 +1402,23 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val .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() @@ -1169,6 +1488,10 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val { failures.push("transaction key is outside declared topology".to_owned()); } + if !transaction_semantics_are_coherent(transaction) { + 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| { @@ -1221,6 +1544,7 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val 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( @@ -1247,20 +1571,58 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val } for reference in references.into_iter().flatten() { if !object_has_only(reference, &["artifactId", "startLine", "endLine"]) - || reference["artifactId"].as_str().is_none_or(str::is_empty) - || reference["startLine"] - .as_u64() - .and_then(|value| u32::try_from(value).ok()) - .is_none() - || reference["endLine"] - .as_u64() - .and_then(|value| u32::try_from(value).ok()) - .is_none() + || 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["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() @@ -1311,23 +1673,78 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val { 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() { - failures.push("source-local evidence is not an array".to_owned()); + 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"]) - || reference["artifactId"].as_str().is_none_or(str::is_empty) - || reference["startLine"] - .as_u64() - .and_then(|value| u32::try_from(value).ok()) - .is_none() - || reference["endLine"] - .as_u64() - .and_then(|value| u32::try_from(value).ok()) - .is_none() + || 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(), + ); } } } @@ -1394,11 +1811,14 @@ fn hierarchy_candidates_are_deterministic_and_collision_resistant() { "{scenario}: artifact IDs are unique" ); - let canonical = hierarchy_candidate_bytes(scenario, artifacts) + let canonical = hierarchy_candidate_bytes(scenario, &manifest) .unwrap_or_else(|error| panic!("{scenario}: {error}")); - let mut reversed_artifacts = artifacts.clone(); - reversed_artifacts.reverse(); - let reversed = hierarchy_candidate_bytes(scenario, &reversed_artifacts) + 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, @@ -1421,8 +1841,10 @@ fn hierarchy_candidates_are_deterministic_and_collision_resistant() { "fragmentComplete": true }); artifacts.push(collision); - let groups = - hierarchy_candidate_groups("healthy-link", &artifacts).expect("candidates project"); + 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| { @@ -1455,11 +1877,14 @@ fn hierarchy_candidates_are_deterministic_and_collision_resistant() { colliding_sender_facts[0].rotation_kind, colliding_sender_facts[1].rotation_kind ); - let canonical = - hierarchy_candidate_bytes("healthy-link", &artifacts).expect("candidates serialize"); - artifacts.reverse(); - let reversed = - hierarchy_candidate_bytes("healthy-link", &artifacts).expect("candidates serialize"); + 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" @@ -1484,13 +1909,8 @@ fn generic_ccm_site_code_token_cannot_create_a_hierarchy_candidate() { parse_fixture_fields(&record.message).is_err(), "generic CCM text must not satisfy the exact hierarchy grammar" ); - let candidates = hierarchy_candidate_groups( - scenario, - manifest["artifacts"] - .as_array() - .expect("generic artifacts are an array"), - ) - .expect("generic artifacts project safely"); + 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" @@ -2650,163 +3070,161 @@ fn hierarchy_review_4826454819_mutations_fail_closed() { read_json("absent-remote-source", "expected.json").expect("absent 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 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 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 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 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 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_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_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 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["artifacts"] - .as_array() - .expect("artifacts are an array"), - ) - .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 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["artifacts"] - .as_array() - .expect("artifacts are an array"), - ) - .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 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 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 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, - ); - drop(audit); + 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!( From 76c72f583f20c3d9a6351df4187513b8b47f1000 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 05:39:22 -0400 Subject: [PATCH 109/422] test(sccm): reproduce missing topology handle --- ...rarchy_and_replication_fixture_contract.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 index ee7575020..023c77e9a 100644 --- 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 @@ -3232,3 +3232,26 @@ fn hierarchy_review_4826454819_mutations_fail_closed() { "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" + ); +} From 878a0514ab457ca3f774d19ad5c4846ca5a1f22e Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 05:39:42 -0400 Subject: [PATCH 110/422] fix(sccm): require present topology handles --- ...cm_server_hierarchy_and_replication_fixture_contract.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index 023c77e9a..7bfcdec17 100644 --- 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 @@ -250,9 +250,10 @@ fn record_matches_topology( artifact_matches_topology(manifest, artifact) && target_host_for_site(manifest, target_site).is_some() } - Some("target") => { - artifact["producerHostHandle"].as_str() == target_host_for_site(manifest, target_site) - } + 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, } } From eda8210162c229f45be041fe6317b1fef2fd886b Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 05:39:59 -0400 Subject: [PATCH 111/422] test(sccm): reproduce DP content fail-open mutations --- ...ver_distribution_point_fixture_contract.rs | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index 4ebda3987..3419dd14d 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -2068,6 +2068,19 @@ fn refresh_artifact_bytes( manifest["artifacts"][artifact_index]["bytesCopied"] = json!(byte_count); } +fn remove_physical_capture_fields(artifact: &mut Value) { + let artifact = artifact + .as_object_mut() + .expect("fixture artifact is an object"); + for field in ["encoding", "collectionLimit", "bytesCopied", "relativePath"] { + artifact.remove(field); + } + artifact["rotation"] + .as_object_mut() + .expect("fixture rotation is an object") + .remove("fragmentComplete"); +} + #[test] fn exact_content_version_dp_topology_and_terminal_evidence_fail_closed() { let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); @@ -3355,3 +3368,268 @@ fn captured_incomplete_fragments_satisfy_the_bounded_next_source() { "captured incomplete fragments were not usable as bounded coverage gaps: {validation:?}" ); } + +#[test] +fn unresolved_required_transfer_coverage_cannot_retain_high_success() { + let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let transfer_artifact_id = healthy_manifest["artifacts"][1]["artifactId"].clone(); + let mut accepted = Vec::new(); + + let mut capped_manifest = healthy_manifest.clone(); + let mut capped_expected = healthy_expected.clone(); + let bytes_copied = capped_manifest["artifacts"][1]["bytesCopied"].clone(); + capped_manifest["artifacts"][1]["captureState"] = json!("capped"); + capped_manifest["artifacts"][1]["collectionLimit"] = + json!({"byteLimit": bytes_copied, "limitApplied": true}); + capped_expected["coverage"][1]["state"] = json!("capped"); + capped_expected["transactions"][0]["observations"] + .as_array_mut() + .expect("observations are an array") + .remove(2); + capped_expected["transactions"][0]["coverageGapArtifactIds"] = + json!([transfer_artifact_id.clone()]); + capped_expected["artifactRequests"] = json!([{ + "sourceId": "server-dp-distribution", + "reasonCode": "coverageCapped" + }]); + if mutation_was_accepted("healthy-package", &capped_manifest, &capped_expected) { + accepted.push("capped transfer evidence was removed from high success"); + } + + let mut denied_manifest = healthy_manifest.clone(); + let mut denied_expected = healthy_expected.clone(); + denied_manifest["artifacts"][1]["captureState"] = json!("accessDenied"); + remove_physical_capture_fields(&mut denied_manifest["artifacts"][1]); + denied_expected["coverage"][1]["state"] = json!("accessDenied"); + denied_expected["transactions"][0]["observations"] + .as_array_mut() + .expect("observations are an array") + .remove(2); + denied_expected["transactions"][0]["coverageGapArtifactIds"] = + json!([transfer_artifact_id.clone()]); + denied_expected["artifactRequests"] = json!([{ + "sourceId": "server-dp-distribution", + "reasonCode": "coverageAccessDenied" + }]); + if mutation_was_accepted("healthy-package", &denied_manifest, &denied_expected) { + accepted.push("access-denied transfer evidence was removed from high success"); + } + + let temporary = temporary_scenario("healthy-package"); + let mut split_manifest = healthy_manifest; + let mut split_expected = healthy_expected; + let relative_path = split_manifest["artifacts"][1]["relativePath"] + .as_str() + .expect("transfer artifact has a path") + .to_owned(); + std::fs::write( + temporary.root.join(&relative_path), + "SYNTHETIC FIXTURE CURRENT FRAGMENT ONLY \n", + ); + std::fs::write(&fixture_path, contents).expect("later recovery evidence is written"); + refresh_artifact_bytes(&mut manifest, 2, &temporary.root); + expected["transactions"][0]["observations"] + .as_array_mut() + .expect("observations are an array") + .push(json!({ + "observationId": "05-available", + "phase": "makeAvailable", + "disposition": "succeeded", + "terminal": false, + "evidence": [{ + "artifactId": "dp-validation-failure-03-provider", + "startLine": 2, + "endLine": 2 + }] + })); + expected["transactions"][0]["lastSuccessfulPhase"] = json!("makeAvailable"); + + assert!( + !mutation_at_root_was_accepted("validation-failure", &temporary.root, &manifest, &expected,), + "a later same-key success retained a stale high confirmed failure" + ); +} + +#[test] +fn incomplete_transaction_gaps_are_bound_to_the_exact_distribution_point() { + let mut manifest = read_json("incomplete", "manifest.json").expect("manifest loads"); + let expected = read_json("incomplete", "expected.json").expect("expected loads"); + manifest["topology"]["distributionPointHandles"] = json!([EXACT_DP, EXACT_DP_02]); + manifest["artifacts"][1]["workflowSubjectHandle"] = json!(EXACT_DP_02); + manifest["artifacts"][2]["producerHostHandle"] = json!(EXACT_DP_02); + manifest["artifacts"][2]["workflowSubjectHandle"] = json!(EXACT_DP_02); + manifest["artifacts"][2]["sanitizedSourcePath"] = + json!("SYNTHETIC://dp-02-root/Logs/SMSDPProv.log"); + + assert!( + !mutation_was_accepted("incomplete", &manifest, &expected), + "DP-02 gaps and requests satisfied an exact DP-01 transaction" + ); +} + +#[test] +fn rotation_split_requires_one_physical_log_family() { + let temporary = temporary_scenario("rotation-boundary"); + let mut manifest = read_json("rotation-boundary", "manifest.json").expect("manifest loads"); + let expected = read_json("rotation-boundary", "expected.json").expect("expected loads"); + let old_relative_path = manifest["artifacts"][1]["relativePath"] + .as_str() + .expect("lo_ artifact has a path") + .to_owned(); + let new_relative_path = "evidence/server-dp-distribution/site/lo_/PkgXferMgr.log".to_owned(); + let new_fixture_path = temporary.root.join(&new_relative_path); + std::fs::create_dir_all( + new_fixture_path + .parent() + .expect("temporary replacement has a parent"), + ) + .expect("temporary replacement parent is created"); + std::fs::copy(temporary.root.join(old_relative_path), &new_fixture_path) + .expect("lo_ fragment is copied into another physical log family"); + manifest["artifacts"][1]["originalBasename"] = json!("PkgXferMgr.log"); + manifest["artifacts"][1]["sanitizedSourcePath"] = + json!("SYNTHETIC://site-root/Logs/PkgXferMgr.lo_"); + manifest["artifacts"][1]["relativePath"] = json!(new_relative_path); + + assert!( + !mutation_at_root_was_accepted("rotation-boundary", &temporary.root, &manifest, &expected,), + "distmgr current plus PkgXferMgr lo_ formed one rotation split" + ); +} + +#[test] +fn parse_failed_raw_bytes_remain_synthetic_and_identity_free() { + let temporary = temporary_scenario("rotation-boundary"); + let mut manifest = read_json("rotation-boundary", "manifest.json").expect("manifest loads"); + let expected = read_json("rotation-boundary", "expected.json").expect("expected loads"); + let relative_path = manifest["artifacts"][2]["relativePath"] + .as_str() + .expect("malformed artifact has a path") + .to_owned(); + let fixture_path = temporary.root.join(&relative_path); + let mut contents = + std::fs::read_to_string(&fixture_path).expect("malformed fixture is readable"); + contents + .push_str("real.user@example.com C:\\Users\\RealUser\\SMSDPProv.log secret=RealSecret\n"); + std::fs::write(&fixture_path, contents).expect("identity-bearing malformed bytes are written"); + refresh_artifact_bytes(&mut manifest, 2, &temporary.root); + + assert!( + !mutation_at_root_was_accepted("rotation-boundary", &temporary.root, &manifest, &expected,), + "parse-failed raw bytes retained uncited identity, path, and secret markers" + ); +} + +#[test] +fn observation_ids_reject_identity_bearing_values_across_output_classes() { + let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let mut healthy_expected = + read_json("healthy-package", "expected.json").expect("expected loads"); + healthy_expected["transactions"][0]["observations"][0]["observationId"] = + json!("01-C:\\Users\\RealUser"); + + let rotation_manifest = + read_json("rotation-boundary", "manifest.json").expect("manifest loads"); + let mut rotation_expected = + read_json("rotation-boundary", "expected.json").expect("expected loads"); + rotation_expected["sourceLocalObservations"][0]["observationId"] = + json!("rotation-01-C:\\Users\\RealUser"); + + let accepted = [ + ( + "transaction observation ID", + mutation_was_accepted("healthy-package", &healthy_manifest, &healthy_expected), + ), + ( + "source-local observation ID", + mutation_was_accepted("rotation-boundary", &rotation_manifest, &rotation_expected), + ), + ] + .into_iter() + .filter_map(|(label, was_accepted)| was_accepted.then_some(label)) + .collect::>(); + + assert!( + accepted.is_empty(), + "identity-bearing public observation IDs were accepted: {accepted:?}" + ); +} + +#[test] +fn rotation_lineage_rejects_identity_bearing_values() { + let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let expected = read_json("healthy-package", "expected.json").expect("expected loads"); + manifest["artifacts"][0]["rotation"]["lineageId"] = json!("C:\\Users\\RealUser\\distmgr"); + + assert!( + !mutation_was_accepted("healthy-package", &manifest, &expected), + "identity-bearing rotation lineage was accepted" + ); +} + +#[test] +fn path_fingerprints_are_bounded_declared_synthetic_identities() { + let manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut identity_bearing = manifest.clone(); + identity_bearing["artifacts"][0]["pathFingerprint"] = json!("synthetic:real-user-hostname"); + if mutation_was_accepted("healthy-package", &identity_bearing, &expected) { + accepted.push("identity-bearing fingerprint"); + } + + let mut oversized = manifest; + oversized["artifacts"][0]["pathFingerprint"] = json!(format!("synthetic:{}", "a".repeat(256))); + if mutation_was_accepted("healthy-package", &oversized, &expected) { + accepted.push("oversized fingerprint"); + } + + assert!( + accepted.is_empty(), + "unbounded or undeclared path fingerprints were accepted: {accepted:?}" + ); +} From 8cec0a87e504d5c9db64650eee647049bb4526c8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 06:54:08 -0400 Subject: [PATCH 112/422] test(sccm): reproduce control request gap --- ...erarchy_and_replication_fixture_contract.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 index 7bfcdec17..0284f5eaa 100644 --- 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 @@ -3256,3 +3256,21 @@ fn hierarchy_coderabbit_5ead896_target_topology_requires_present_handles() { "two absent target handles cannot satisfy exact topology" ); } + +#[test] +fn hierarchy_coderabbit_878a051_control_requests_include_target_rcmctrl() { + let mut manifest = + read_json("absent-remote-source", "manifest.json").expect("absent manifest loads"); + let mut 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"); + expected["artifactRequests"][0]["sourceId"] = serde_json::json!("server-hierarchy-control"); + expected["artifactRequests"][0]["basenames"] = serde_json::json!(["rcmctrl.log"]); + + let failures = artifact_request_failures("absent-remote-source", &manifest, &expected); + assert!( + failures.is_empty(), + "target-side rcmctrl coverage request is exact: {failures:?}" + ); +} From da411643afb7ef84a41f1f2290667e7d8517c45b Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 06:54:28 -0400 Subject: [PATCH 113/422] fix(sccm): admit exact control requests --- ...hierarchy_and_replication_fixture_contract.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) 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 index 0284f5eaa..e5c64c28a 100644 --- 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 @@ -990,15 +990,13 @@ fn artifact_request_failures(scenario: &str, manifest: &Value, expected: &Value) let mut sorted_basenames = basenames.clone(); sorted_basenames.sort_unstable(); sorted_basenames.dedup(); - let source_owns_basenames = match source_id { - Some("server-hierarchy-control") => basenames - .iter() - .all(|basename| matches!(*basename, "replmgr.log")), - Some("server-hierarchy-transfer") => basenames - .iter() - .all(|basename| matches!(*basename, "sender.log" | "sender.lo_" | "despool.log")), - _ => false, - }; + 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)) From 857f196cd1b5b3a684c47b091f07f58b9e87d8a8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 06:56:08 -0400 Subject: [PATCH 114/422] test(sccm): define shared hierarchy predicates --- ...rarchy_and_replication_fixture_contract.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 index e5c64c28a..17e1a6dfb 100644 --- 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 @@ -3272,3 +3272,31 @@ fn hierarchy_coderabbit_878a051_control_requests_include_target_rcmctrl() { "target-side rcmctrl coverage request is exact: {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" + ); +} From 0a6ba32c6e668d48bcd9ceee6d7d185305b18775 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 06:58:48 -0400 Subject: [PATCH 115/422] fix(sccm): unify hierarchy validation predicates --- ...rarchy_and_replication_fixture_contract.rs | 57 +++++++++---------- 1 file changed, 26 insertions(+), 31 deletions(-) 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 index 17e1a6dfb..742d3d942 100644 --- 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 @@ -188,6 +188,21 @@ fn artifact_has_exact_source_tuple(artifact: &Value) -> bool { }) } +fn artifact_has_exact_public_provenance(artifact: &Value) -> bool { + artifact["producerHostHandle"] + .as_str() + .is_some_and(safe_server_handle) + && artifact["sanitizedSourcePath"] + .as_str() + .is_some_and(|value| safe_segmented_path(value, "SYNTHETIC://")) + && artifact["pathFingerprint"].as_str().is_some_and(|value| { + value + .strip_prefix("synthetic:") + .is_some_and(|suffix| !suffix.is_empty()) + }) + && artifact["sourceVersion"].as_str() == Some(EXACT_SOURCE_VERSION) +} + fn target_host_for_site<'a>(manifest: &'a Value, site: &str) -> Option<&'a str> { let hosts = std::iter::once(( manifest["topology"]["targetSiteCode"].as_str(), @@ -592,7 +607,7 @@ fn transaction_semantics_are_coherent(transaction: &Value) -> bool { let Some(terminal) = observation["terminal"].as_bool() else { return false; }; - if !matches!(disposition, "succeeded" | "failed" | "retrying") { + if !observation_disposition_is_coherent(disposition, terminal) { return false; } retrying |= disposition == "retrying"; @@ -620,6 +635,13 @@ fn transaction_semantics_are_coherent(transaction: &Value) -> bool { 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) -> &'static [&'static str] { match scenario { "absent-remote-source" => &["hierarchy:msg-absent-01:LAB:CHD:link-lab-chd"], @@ -1192,18 +1214,10 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val if artifact["producerRole"] != "siteServer" || !matches!(artifact["direction"].as_str(), Some("origin" | "target")) || !artifact_has_exact_source_tuple(artifact) - || artifact["sourceVersion"].as_str() != Some(EXACT_SOURCE_VERSION) + || !artifact_has_exact_public_provenance(artifact) || artifact["collectedUtc"] .as_str() .is_none_or(|value| DateTime::parse_from_rfc3339(value).is_err()) - || !artifact["sanitizedSourcePath"] - .as_str() - .is_some_and(|value| safe_segmented_path(value, "SYNTHETIC://")) - || !artifact["pathFingerprint"].as_str().is_some_and(|value| { - value - .strip_prefix("synthetic:") - .is_some_and(|suffix| !suffix.is_empty()) - }) { failures.push(format!("{artifact_id}: invalid typed provenance")); } @@ -2037,21 +2051,7 @@ fn hierarchy_manifest_sources_and_physical_evidence_are_bounded() { "{context}: source escapes the raw CCM hierarchy catalog" )); } - if !artifact["producerHostHandle"] - .as_str() - .is_some_and(|value| value.starts_with("safe:server:")) - || !artifact["sanitizedSourcePath"] - .as_str() - .is_some_and(|value| safe_segmented_path(value, "SYNTHETIC://")) - || !artifact["pathFingerprint"].as_str().is_some_and(|value| { - value - .strip_prefix("synthetic:") - .is_some_and(|suffix| !suffix.is_empty()) - }) - || !artifact["sourceVersion"] - .as_str() - .is_some_and(|value| value.starts_with("5.00.TEST.") && value.len() > 10) - { + if !artifact_has_exact_public_provenance(artifact) { failures.push(format!("{context}: unsafe or empty provenance")); } if artifact["pathFingerprint"] @@ -2282,12 +2282,7 @@ fn hierarchy_transactions_require_exact_keys_topology_time_and_citations() { )); } prior_phase = phase_index; - if !matches!( - (disposition, terminal), - ("succeeded", false | true) - | ("failed", true) - | ("retrying" | "deferred", false) - ) { + if !observation_disposition_is_coherent(disposition, terminal) { failures.push(format!( "{scenario}/{observation_id}: incoherent disposition/terminality" )); From f0683bc9116d5fec121a505d2f7f1b8e0371f64e Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 07:05:22 -0400 Subject: [PATCH 116/422] test(sccm): freeze management point reducer contract --- .../tests/sccm_server_management_point.rs | 593 ++++++++++++++++++ 1 file changed, 593 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/sccm_server_management_point.rs 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..67ca1f4b3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -0,0 +1,593 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::{ + analyze_management_point, declared_source_catalog, normalize_ccm_artifact, SccmArtifact, + SccmArtifactFamily, SccmCoverageState, SccmManagementPointBundle, + SccmManagementPointSource, SccmManagementPointTopology, 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 { + assert_eq!( + source.role, "managementPoint", + "MP fixtures must preserve their server role" + ); + let artifact = SccmArtifact { + artifact_id: source.artifact_id, + display_name: source.original_basename, + original_path: None, + host: None, + role: SccmRole::ManagementPoint, + 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 expected_transaction_projection(expected: &Value) -> Vec { + expected["transactions"] + .as_array() + .expect("expected transactions") + .iter() + .map(|transaction| { + json!({ + "transactionId": transaction["transactionId"], + "phase": transaction["phase"], + "state": transaction["state"], + "lastSuccessfulPhase": transaction["lastSuccessfulPhase"], + "classification": transaction["classification"], + "confidence": transaction["confidence"], + "coverageGapArtifactIds": transaction["coverageGapArtifactIds"], + "nextArtifactLogicalId": transaction["nextArtifact"]["logicalArtifactId"], + }) + }) + .collect() +} + +fn actual_transaction_projection(analysis: &Value) -> Vec { + analysis["transactions"] + .as_array() + .expect("analysis transactions") + .iter() + .map(|transaction| { + json!({ + "transactionId": transaction["transactionId"], + "phase": transaction["phase"], + "state": transaction["state"], + "lastSuccessfulPhase": transaction["lastSuccessfulPhase"], + "classification": transaction["classification"], + "confidence": transaction["confidence"], + "coverageGapArtifactIds": transaction["coverageGapArtifactIds"], + "nextArtifactLogicalId": transaction["nextArtifacts"] + .as_array() + .and_then(|requests| requests.first()) + .map(|request| request["logicalArtifactId"].clone()) + .unwrap_or(Value::Null), + }) + }) + .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["artifactId"] == expected_reference["artifactId"] + }), + "{scenario}: {transaction_id} omitted expected artifact evidence" + ); + } + + let observations = actual["observations"] + .as_array() + .expect("transaction observations"); + assert!(!observations.is_empty(), "{scenario}: observations"); + 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_id = if actual { + observation["nextArtifacts"] + .as_array() + .and_then(|requests| requests.first()) + .map(|request| request["logicalArtifactId"].clone()) + .unwrap_or(Value::Null) + } else { + observation["nextArtifact"]["logicalArtifactId"].clone() + }; + json!({ + "observationId": observation["observationId"], + "phase": observation["phase"], + "classification": observation["classification"], + "confidence": observation["confidence"], + "correlationEligible": observation["correlationEligible"], + "nextArtifactLogicalId": next_logical_id, + }) + }) + .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(&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 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(&bundle)).expect("analysis JSON"); + + let mut reordered = bundle.clone(); + reordered.sources.reverse(); + reordered.evidence.reverse(); + let actual = + serde_json::to_string(&analyze_management_point(&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(&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(&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" + ); +} + +#[test] +fn management_point_catalog_declares_every_reducer_source() { + let sources = declared_source_catalog() + .into_iter() + .filter(|source| { + source.role == SccmRole::ManagementPoint + && source.family == SccmArtifactFamily::ManagementPoint + }) + .map(|source| (source.basename, source.logical_name)) + .collect::>(); + + for expected in [ + ("MP_CliReg.log", "mpCliReg"), + ("MP_GetAuth.log", "mpGetAuth"), + ("MP_GetPolicy.log", "mpGetPolicy"), + ("MP_Location.log", "mpLocation"), + ("MP_RegistrationManager.log", "mpRegistrationManager"), + ("mpcontrol.log", "mpcontrol"), + ] { + let expected = (expected.0.to_owned(), expected.1.to_owned()); + assert!(sources.contains(&expected), "missing MP source {expected:?}"); + } +} From 0d940081f37a3d55c00d3730f81e5e54ef0b8932 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 07:05:34 -0400 Subject: [PATCH 117/422] fix(sccm): close DP content evidence gaps --- ...ver_distribution_point_fixture_contract.rs | 230 ++++++++++++++++-- 1 file changed, 213 insertions(+), 17 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index 3419dd14d..c3b561a1f 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -36,6 +36,63 @@ const EXACT_DP: &str = "safe:dp:lab-dp-01"; const EXACT_DP_02: &str = "safe:dp:lab-dp-02"; const EXACT_SITE_SERVER: &str = "safe:server:lab-pri-01"; const EXACT_CLIENT: &str = "safe:client:lab-client-01"; +const APPROVED_PATH_FINGERPRINTS: &[&str] = &[ + "synthetic:absent-distmgr", + "synthetic:absent-provider", + "synthetic:client-only-data-transfer", + "synthetic:client-only-server-absent", + "synthetic:distribution-failure", + "synthetic:healthy-distmgr", + "synthetic:healthy-pkgxfer", + "synthetic:healthy-provider", + "synthetic:incomplete-distmgr", + "synthetic:incomplete-pkgxfer", + "synthetic:incomplete-provider", + "synthetic:retry-distmgr", + "synthetic:retry-pkgxfer", + "synthetic:rotation-current", + "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", +]; +const APPROVED_ROTATION_LINEAGES: &[&str] = &[ + "absent-distmgr", + "absent-provider", + "client-only-data-transfer", + "client-only-server-absent", + "distribution-failure", + "healthy-distmgr", + "healthy-pkgxfer", + "healthy-provider", + "incomplete-distmgr", + "incomplete-pkgxfer", + "incomplete-provider", + "retry-distmgr", + "retry-pkgxfer", + "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", +]; fn corpus_root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -193,8 +250,10 @@ struct ParsedArtifact { state: String, source_id: String, role: String, + producer_host_handle: String, basename: String, workflow_subject_handle: Option, + workflow_subject_basis: Option, rotation_kind: String, rotation_lineage: String, fragment_complete: Option, @@ -208,6 +267,16 @@ struct ParsedScenario { distribution_point_handles: BTreeSet, } +fn artifact_applies_to_distribution_point( + artifact: &ParsedArtifact, + distribution_point_handle: &str, +) -> bool { + artifact.workflow_subject_handle.as_deref() == Some(distribution_point_handle) + || (artifact.workflow_subject_handle.is_none() + && artifact.workflow_subject_basis.as_deref() == Some("manifestTopology") + && artifact.role == "siteServer") +} + fn parse_fixture_fields(message: &str) -> Result, String> { let message = message .strip_prefix("[sccm-public-message-v1] ") @@ -316,13 +385,60 @@ fn sanitized_source_path_is_bounded(source_path: &str, basename: &str, rotation: } fn path_fingerprint_is_safe(path_fingerprint: &str) -> bool { - path_fingerprint - .strip_prefix("synthetic:") - .is_some_and(|suffix| { + path_fingerprint.len() <= 64 && APPROVED_PATH_FINGERPRINTS.contains(&path_fingerprint) +} + +fn rotation_lineage_is_safe(rotation_lineage: &str) -> bool { + rotation_lineage.len() <= 64 && APPROVED_ROTATION_LINEAGES.contains(&rotation_lineage) +} + +fn transaction_observation_id_is_safe(observation_id: &str) -> bool { + observation_id.len() <= 64 + && observation_id + .as_bytes() + .get(..2) + .is_some_and(|prefix| prefix.iter().all(u8::is_ascii_digit)) + && observation_id.as_bytes().get(2) == Some(&b'-') + && observation_id.as_bytes().get(3..).is_some_and(|suffix| { !suffix.is_empty() && suffix - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') + }) +} + +fn source_local_observation_id_is_safe(observation_id: &str) -> bool { + observation_id.len() <= 64 + && (observation_id + .strip_prefix("client-control-") + .is_some_and(|suffix| { + suffix.len() == 2 && suffix.bytes().all(|byte| byte.is_ascii_digit()) + }) + || observation_id + .strip_prefix("rotation-") + .is_some_and(|suffix| { + suffix.split_once('-').is_some_and(|(ordinal, label)| { + ordinal.len() == 2 + && ordinal.bytes().all(|byte| byte.is_ascii_digit()) + && !label.is_empty() + && label.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' + }) + }) + })) +} + +fn raw_fixture_bytes_are_sanitized(content: &str) -> bool { + !content.is_empty() + && content.lines().all(|line| { + let lower = line.to_ascii_lowercase(); + !line.is_empty() + && line.contains("SYNTHETIC FIXTURE") + && !line.contains(['@', '\\']) + && !lower.contains("/users/") + && !lower.contains("secret=") + && !lower.contains("password=") + && !lower.contains("token=") }) } @@ -634,14 +750,24 @@ fn validate_manifest( )); } let rotation_kind = artifact["rotation"]["kind"].as_str(); - let rotation_lineage = - match required_nonempty_string(&artifact["rotation"], "lineageId", &context) { - Ok(value) => value.to_owned(), - Err(error) => { - failures.push(error); - String::new() + let rotation_lineage = match required_nonempty_string( + &artifact["rotation"], + "lineageId", + &context, + ) { + Ok(value) => { + if !rotation_lineage_is_safe(value) { + failures.push(format!( + "{artifact_id} rotation lineage is not a bounded declared synthetic identity" + )); } - }; + value.to_owned() + } + Err(error) => { + failures.push(error); + String::new() + } + }; let physical_capture = matches!(state, "captured" | "capped" | "parseFailed"); let artifact_collected_utc = match required_string(artifact, "collectedUtc", &context) .and_then(|value| { @@ -801,9 +927,19 @@ fn validate_manifest( "{artifact_id} has incoherent raw-byte collection-limit provenance" )); } - let content = String::from_utf8_lossy(&bytes); - if !content.contains("SYNTHETIC FIXTURE") { - failures.push(format!("{artifact_id} lacks a synthetic fixture marker")); + let content = match std::str::from_utf8(&bytes) { + Ok(value) => value, + Err(error) => { + failures.push(format!( + "{artifact_id} is not valid UTF-8 under its declared encoding: {error}" + )); + "" + } + }; + if !raw_fixture_bytes_are_sanitized(content) { + failures.push(format!( + "{artifact_id} contains non-synthetic or identity-bearing raw fixture bytes" + )); } for (line_index, _) in content.lines().enumerate() { let Ok(line_number) = u32::try_from(line_index + 1) else { @@ -835,7 +971,7 @@ fn validate_manifest( coverage: coverage_model.clone(), encoding: encoding.map(str::to_owned), }; - let normalized = normalize_ccm_artifact(artifact_model, &content); + let normalized = normalize_ccm_artifact(artifact_model, content); if fragment_complete == Some(false) && !normalized.is_empty() { failures.push(format!( "{artifact_id} exposes a logical record from an incomplete rotation fragment" @@ -952,8 +1088,10 @@ fn validate_manifest( state: state.to_owned(), source_id: source_id.to_owned(), role: role.to_owned(), + producer_host_handle: producer_host_handle.unwrap_or_default().to_owned(), basename: basename.to_owned(), workflow_subject_handle: workflow_subject_handle.map(str::to_owned), + workflow_subject_basis: workflow_subject_basis.map(str::to_owned), rotation_kind: artifact["rotation"]["kind"] .as_str() .unwrap_or_default() @@ -1265,6 +1403,17 @@ fn validate_expected( continue; } }; + let required_incomplete_artifact_ids = parsed + .artifacts + .iter() + .filter(|(_, artifact)| { + artifact.source_id == "server-dp-distribution" + && artifact_has_incomplete_coverage(artifact) + && artifact_applies_to_distribution_point(artifact, &key_fields["DpHandle"]) + }) + .map(|(artifact_id, _)| artifact_id.as_str()) + .collect::>(); + let has_unresolved_required_coverage = !required_incomplete_artifact_ids.is_empty(); let expected_id = format!( "dp:{}:{}:v{}:{}", key_fields["PackageId"], @@ -1312,6 +1461,7 @@ fn validate_expected( let mut terminal_success_phase = None; let mut terminal_failure = false; let mut terminal_deferred = false; + let mut observed_after_terminal_failure = false; let mut cites_capped_evidence = false; let mut previous_utc = i64::MIN; let mut previous_phase = 0usize; @@ -1329,6 +1479,11 @@ fn validate_expected( "{transaction_id} contains duplicate observationId {observation_id}" )); } + if !transaction_observation_id_is_safe(observation_id) { + failures.push(format!( + "{transaction_id} contains an unbounded or identity-bearing observationId" + )); + } let phase = required_string(observation, "phase", observation_id).unwrap_or("invalid"); let disposition = required_string(observation, "disposition", observation_id).unwrap_or("invalid"); @@ -1437,6 +1592,9 @@ fn validate_expected( } previous_utc = utc; } + if terminal_failure { + observed_after_terminal_failure = true; + } match (disposition, terminal) { ("succeeded", true) => { latest_success = latest_success.max(phase_index); @@ -1482,12 +1640,15 @@ fn validate_expected( && computed_last_success == Some("serveOrReport") && !terminal_failure && !cites_capped_evidence + && !has_unresolved_required_coverage && confidence == "high" && confidence_ceiling == "high" => {} ("failed", "confirmedFailure") if terminal_failure && !terminal_success + && !observed_after_terminal_failure && !cites_capped_evidence + && !has_unresolved_required_coverage && confidence == "high" && confidence_ceiling == "high" => {} ("deferred", "blockedOrDeferred") @@ -1495,6 +1656,7 @@ fn validate_expected( && !terminal_failure && !terminal_success && !cites_capped_evidence + && !has_unresolved_required_coverage && confidence == "medium" && confidence_ceiling == "medium" => {} ("incomplete", "insufficientEvidence") @@ -1532,6 +1694,12 @@ fn validate_expected( "{transaction_id} coverage gaps must be sorted and unique" )); } + let declared_gap_ids = gap_ids.iter().copied().collect::>(); + if declared_gap_ids != required_incomplete_artifact_ids { + failures.push(format!( + "{transaction_id} coverage gaps are not the exact required DP-bound source gaps" + )); + } for artifact_id in &gap_ids { match parsed.artifacts.get(*artifact_id) { Some(artifact) if artifact_has_incomplete_coverage(artifact) => {} @@ -1552,13 +1720,13 @@ fn validate_expected( "{transaction_id} incomplete state lacks a bounded noncomplete next source" )); } - let declared_gap_ids = gap_ids.iter().copied().collect::>(); let expected_gap_ids = parsed .artifacts .iter() .filter(|(_, artifact)| { Some(artifact.source_id.as_str()) == next_source && artifact_has_incomplete_coverage(artifact) + && artifact_applies_to_distribution_point(artifact, &key_fields["DpHandle"]) }) .map(|(artifact_id, _)| artifact_id.as_str()) .collect::>(); @@ -1621,6 +1789,9 @@ fn validate_expected( "source-local observations contain duplicate observationId {observation_id}" )); } + if !source_local_observation_id_is_safe(observation_id) { + failures.push("source-local observationId is unbounded or identity-bearing".to_owned()); + } reject_unknown_fields( observation, &[ @@ -1756,6 +1927,26 @@ fn validate_expected( .iter() .map(|artifact| artifact.source_id.as_str()) .collect::>(); + let roles = artifacts + .iter() + .map(|artifact| artifact.role.as_str()) + .collect::>(); + let producers = artifacts + .iter() + .map(|artifact| artifact.producer_host_handle.as_str()) + .collect::>(); + let basenames = artifacts + .iter() + .map(|artifact| artifact.basename.to_ascii_lowercase()) + .collect::>(); + let workflow_subject_handles = artifacts + .iter() + .map(|artifact| artifact.workflow_subject_handle.as_deref()) + .collect::>(); + let workflow_subject_bases = artifacts + .iter() + .map(|artifact| artifact.workflow_subject_basis.as_deref()) + .collect::>(); let lineages = artifacts .iter() .map(|artifact| artifact.rotation_lineage.as_str()) @@ -1767,6 +1958,11 @@ fn validate_expected( cited_artifact_ids == unique_artifact_ids && artifacts.len() >= 2 && source_ids.len() == 1 + && roles.len() == 1 + && producers.len() == 1 + && basenames.len() == 1 + && workflow_subject_handles.len() == 1 + && workflow_subject_bases.len() == 1 && lineages.len() == 1 && lineages.first().is_some_and(|lineage| !lineage.is_empty()) && rotation_kinds.len() >= 2 From 93ddcf061c8611fc794ad467367b8e52dd4e5941 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 07:26:04 -0400 Subject: [PATCH 118/422] test(sccm): reproduce hierarchy rotation alias --- ...rarchy_and_replication_fixture_contract.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 index 742d3d942..398bcec72 100644 --- 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 @@ -3295,3 +3295,26 @@ fn hierarchy_coderabbit_878a051_shared_predicates_stay_narrow() { "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" + ); +} From 15f6db45866232a93cd2003d3be41fce45a3750d Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 07:27:15 -0400 Subject: [PATCH 119/422] fix(sccm): use canonical hierarchy rotation tag --- .../hierarchy_and_replication/rotation-boundary/manifest.json | 2 +- .../sccm_server_hierarchy_and_replication_fixture_contract.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 479639e5a..499ff265e 100644 --- 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 @@ -7,6 +7,6 @@ "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":"lo_","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_"} + {"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/sccm_server_hierarchy_and_replication_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_hierarchy_and_replication_fixture_contract.rs index 398bcec72..d2e1a1c3b 100644 --- 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 @@ -110,7 +110,7 @@ fn coverage_state(value: &str) -> Option { fn rotation(value: &Value) -> Option { match value["kind"].as_str()? { "current" if value.get("value").is_none() => Some(SccmRotation::Current), - "lo_" if value.get("value").is_none() => Some(SccmRotation::LoUnderscore), + "loUnderscore" if value.get("value").is_none() => Some(SccmRotation::LoUnderscore), "numbered" => value["value"] .as_u64() .and_then(|number| u32::try_from(number).ok()) @@ -832,7 +832,7 @@ fn coverage_request_basis( artifact["rotation"].get("value"), ), (Some("sender.log"), Some("current"), None) - | (Some("sender.lo_"), Some("lo_"), None) + | (Some("sender.lo_"), Some("loUnderscore"), None) ) }); let canonical_basenames = From 15e29dd057df60438a56c2ffaf91e2fc9c6a098d Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 07:28:20 -0400 Subject: [PATCH 120/422] test(sccm): reproduce hierarchy matrix fallback --- ...erarchy_and_replication_fixture_contract.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 index d2e1a1c3b..f74bc9aa3 100644 --- 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 @@ -3318,3 +3318,21 @@ fn hierarchy_coderabbit_0a6ba32_rotation_uses_shared_wire_kind() { "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") + ); +} From d78dc49bf1aae1ebb05b7671340e68172b08c7d8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 07:30:02 -0400 Subject: [PATCH 121/422] test(sccm): fail closed unknown hierarchy matrices --- ...rarchy_and_replication_fixture_contract.rs | 94 ++++++++++++------- 1 file changed, 59 insertions(+), 35 deletions(-) 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 index f74bc9aa3..b8cb597c1 100644 --- 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 @@ -642,29 +642,31 @@ fn observation_disposition_is_coherent(disposition: &str, terminal: bool) -> boo ) } -fn expected_transaction_ids(scenario: &str) -> &'static [&'static str] { +fn expected_transaction_ids(scenario: &str) -> Option<&'static [&'static str]> { match scenario { - "absent-remote-source" => &["hierarchy:msg-absent-01:LAB:CHD:link-lab-chd"], - "backlog-retry" => &["hierarchy:msg-backlog-01:LAB:CHD:link-lab-chd"], - "clock-offset-unknown" => &["hierarchy:msg-clock-01:LAB:CHD:link-lab-chd"], - "healthy-link" => &["hierarchy:msg-healthy-01:LAB:CHD:link-lab-chd"], - "receiver-processing-failure" => &["hierarchy:msg-receiver-01:LAB:CHD:link-lab-chd"], - "recovery" => &["hierarchy:msg-recovery-01:LAB:CHD:link-lab-chd"], - "sender-failure" => &[ + "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", - ], - "incomplete" | "rotation-boundary" | "topology-mismatch" => &[], - _ => &[], + ]), + "generic-site-token" | "incomplete" | "rotation-boundary" | "topology-mismatch" => { + Some(&[]) + } + _ => None, } } -fn expected_observation_ids(scenario: &str) -> &'static [&'static str] { +fn expected_observation_ids(scenario: &str) -> Option<&'static [&'static str]> { match scenario { - "absent-remote-source" => &["absent-01-send"], - "backlog-retry" => &["backlog-01-queue"], - "clock-offset-unknown" => &["clock-01-send", "clock-02-process"], - "healthy-link" => &[ + "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", @@ -672,31 +674,41 @@ fn expected_observation_ids(scenario: &str) -> &'static [&'static str] { "healthy-05-process", "healthy-06-acknowledge", "healthy-07-terminal", - ], - "receiver-processing-failure" => &[ + ]), + "receiver-processing-failure" => Some(&[ "receiver-01-send", "receiver-02-receive", "receiver-03-process", - ], - "recovery" => &[ + ]), + "recovery" => Some(&[ "recovery-01-retry", "recovery-02-send", "recovery-03-receive", "recovery-04-process", "recovery-05-terminal", - ], - "sender-failure" => &["sender-01-chd-failure", "sender-02-sec-failure"], - "incomplete" | "rotation-boundary" | "topology-mismatch" => &[], - _ => &[], + ]), + "sender-failure" => Some(&["sender-01-chd-failure", "sender-02-sec-failure"]), + "generic-site-token" | "incomplete" | "rotation-boundary" | "topology-mismatch" => { + Some(&[]) + } + _ => None, } } -fn expected_source_local_ids(scenario: &str) -> &'static [&'static str] { +fn expected_source_local_ids(scenario: &str) -> Option<&'static [&'static str]> { match scenario { - "incomplete" => &["incomplete-01-fragment"], - "rotation-boundary" => &["rotation-01-split"], - "topology-mismatch" => &["mismatch-01-origin", "mismatch-02-target"], - _ => &[], + "incomplete" => Some(&["incomplete-01-fragment"]), + "rotation-boundary" => Some(&["rotation-01-split"]), + "topology-mismatch" => Some(&["mismatch-01-origin", "mismatch-02-target"]), + "absent-remote-source" + | "backlog-retry" + | "clock-offset-unknown" + | "generic-site-token" + | "healthy-link" + | "receiver-processing-failure" + | "recovery" + | "sender-failure" => Some(&[]), + _ => None, } } @@ -1438,8 +1450,12 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val .flatten() .filter_map(|transaction| transaction["transactionId"].as_str()) .collect::>(); - if transaction_ids != expected_transaction_ids(scenario) { - failures.push("transaction identity/cardinality matrix changed".to_owned()); + 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() { if !object_has_only( @@ -1643,8 +1659,12 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val .flat_map(|transaction| transaction["observations"].as_array().into_iter().flatten()) .filter_map(|observation| observation["observationId"].as_str()) .collect::>(); - if observation_ids != expected_observation_ids(scenario) { - failures.push("observation identity/cardinality matrix changed".to_owned()); + 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() { @@ -1766,8 +1786,12 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val .flatten() .filter_map(|observation| observation["observationId"].as_str()) .collect::>(); - if source_local_ids != expected_source_local_ids(scenario) { - failures.push("source-local identity/cardinality matrix changed".to_owned()); + 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)); From ad1a147ba2003872139d4344b5e8e374576eae09 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 07:38:41 -0400 Subject: [PATCH 122/422] test(sccm): reproduce rotated sender skip --- ...erarchy_and_replication_fixture_contract.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 index b8cb597c1..87d025901 100644 --- 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 @@ -3360,3 +3360,21 @@ fn hierarchy_coderabbit_0a6ba32_identity_matrices_reject_unknown_scenarios() { 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]["rotation"]["kind"] = serde_json::json!("loUnderscore"); + + 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" + ); +} From fb4e6306568a15c51da9189d6d773130c2e1e805 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 07:39:22 -0400 Subject: [PATCH 123/422] fix(sccm): admit complete rotated sender records --- .../sccm_server_hierarchy_and_replication_fixture_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 87d025901..de7c7f4ee 100644 --- 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 @@ -543,7 +543,7 @@ fn phase_is_owned_by(basename: &str, phase: &str) -> bool { matches!( (basename, phase), ("replmgr.log", "initiate" | "queueOrSerialize") - | ("sender.log", "send") + | ("sender.log" | "sender.lo_", "send") | ("despool.log", "receive" | "process" | "healthyOrTerminal") | ("rcmctrl.log", "acknowledge" | "healthyOrTerminal") ) From aa0f0809f3c069c519aa5a98ad08306f9d5bb804 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 07:41:28 -0400 Subject: [PATCH 124/422] feat(sccm): analyze management point evidence --- crates/cmtraceopen-parser/src/sccm/mod.rs | 2 + .../cmtraceopen-parser/src/sccm/server/mod.rs | 3 + .../sccm/server/windows/management_point.rs | 1554 +++++++++++++++++ .../src/sccm/server/windows/mod.rs | 3 + .../tests/sccm_server_management_point.rs | 25 +- 5 files changed, 1574 insertions(+), 13 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/server/mod.rs create mode 100644 crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs create mode 100644 crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs diff --git a/crates/cmtraceopen-parser/src/sccm/mod.rs b/crates/cmtraceopen-parser/src/sccm/mod.rs index d5f1b55d3..2487f9fb0 100644 --- a/crates/cmtraceopen-parser/src/sccm/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/mod.rs @@ -5,6 +5,7 @@ mod ingest; mod keys; pub mod models; mod rotation; +pub mod server; mod signals; pub use catalog::*; @@ -12,4 +13,5 @@ pub use findings::*; pub use ingest::*; pub use keys::*; pub use models::*; +pub use server::*; pub use signals::*; 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..b64742ed7 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/mod.rs @@ -0,0 +1,3 @@ +pub mod windows; + +pub use windows::*; 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..dcac1071f --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs @@ -0,0 +1,1554 @@ +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; + +use crate::models::log_entry::Severity; +use crate::sccm::{ + SccmArtifact, SccmArtifactRequest, SccmConfidence, SccmCoverageState, SccmEvidence, + SccmEvidenceRef, SccmFinding, SccmFindingBuilder, SccmFindingClass, SccmFindingCoverageGap, + SccmKeyConfidence, SccmPhase, SccmRole, SccmTerminalEvidence, SccmTimestamp, +}; + +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.0000"; +const MP_AUTH_GROUP: &str = "server-mp-auth"; +const MP_POLICY_GROUP: &str = "server-mp-policy"; +const MP_IIS_GROUP: &str = "server-mp-iis"; + +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 timestamp: SccmTimestamp, + pub evidence: SccmEvidenceRef, +} + +#[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, +} + +pub fn analyze_management_point(bundle: &SccmManagementPointBundle) -> SccmManagementPointAnalysis { + let source_by_artifact = bundle + .sources + .iter() + .filter(|source| safe_opaque_id(&source.artifact.artifact_id)) + .map(|source| (source.artifact.artifact_id.as_str(), source)) + .collect::>(); + + let topology_is_valid = valid_site_code(&bundle.topology.site_code) + && valid_safe_handle(&bundle.topology.management_point_host_handle, "safe:mp:"); + 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_is_admitted(source) + || !safe_evidence_reference(&evidence.reference) + || evidence.role != SccmRole::ManagementPoint + || source.artifact.coverage != SccmCoverageState::Captured + || source.fragment_complete != Some(true) + || !topology_is_valid + { + if safe_evidence_reference(&evidence.reference) { + rejected_references.push(evidence.reference.clone()); + } + continue; + } + + match parse_fact(evidence, source) { + Some(fact) + if fact.site_code == bundle.topology.site_code + && 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, 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 latest_failure = latest_fact( + facts + .iter() + .copied() + .filter(|fact| fact.outcome == FactOutcome::Failed && fact.terminal), + ); + let latest_success = latest_fact( + facts + .iter() + .copied() + .filter(|fact| fact.outcome == FactOutcome::Succeeded), + ); + + match (latest_failure, latest_success) { + (Some(failure), Some(success)) + if failure.timestamp.utc_millis == success.timestamp.utc_millis => + { + let instant = failure.timestamp.utc_millis; + PhaseDecision { + kind: PhaseDecisionKind::Contradictory, + decisive: facts + .iter() + .copied() + .filter(|fact| fact.timestamp.utc_millis == instant) + .collect(), + ordering_millis: instant, + } + } + (Some(failure), Some(success)) + if success.timestamp.utc_millis > failure.timestamp.utc_millis => + { + PhaseDecision { + kind: PhaseDecisionKind::Succeeded, + decisive: vec![success], + ordering_millis: success.timestamp.utc_millis, + } + } + (Some(failure), _) => PhaseDecision { + kind: PhaseDecisionKind::Failed, + decisive: vec![failure], + ordering_millis: failure.timestamp.utc_millis, + }, + (None, Some(success)) => PhaseDecision { + kind: PhaseDecisionKind::Succeeded, + decisive: vec![success], + ordering_millis: success.timestamp.utc_millis, + }, + (None, None) => { + let latest_deferred = latest_fact( + facts + .iter() + .copied() + .filter(|fact| fact.outcome == FactOutcome::Deferred), + ); + if let Some(deferred) = latest_deferred { + PhaseDecision { + kind: PhaseDecisionKind::Deferred, + decisive: vec![deferred], + ordering_millis: deferred.timestamp.utc_millis, + } + } else { + PhaseDecision { + kind: PhaseDecisionKind::UnusableTime, + decisive: facts.to_vec(), + ordering_millis: None, + } + } + } + } +} + +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, + 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 = facts + .iter() + .filter(|fact| fact.policy_id.is_some()) + .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, + timestamp: fact.timestamp.clone(), + evidence: 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)?; + if terminal && !has_nonzero_result(message) { + return None; + } + + let request_id = normalize_uuid(&token_value(message, "RequestId")?)?; + let policy_id = match token_value(message, "PolicyId") { + Some(value) => Some(normalize_uuid(&value)?), + None => None, + }; + let client_handle = token_value(message, "ClientHandle")?; + let site_code = token_value(message, "SiteCode")?; + let management_point_host_handle = token_value(message, "MPHandle")?; + if !valid_safe_handle(&client_handle, "safe:client:") + || !valid_site_code(&site_code) + || !valid_safe_handle(&management_point_host_handle, "safe:mp:") + { + 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 = message.to_ascii_lowercase(); + let succeeded = FactOutcome::Succeeded; + let failed = FactOutcome::Failed; + let deferred = FactOutcome::Deferred; + + match producer { + "MP_GetAuth" if lowercase.contains("receive request succeeded") => { + Some((SccmManagementPointPhase::ReceiveRequest, succeeded, false)) + } + "MP_GetAuth" if lowercase.contains("authenticate succeeded") => { + Some((SccmManagementPointPhase::Authenticate, succeeded, false)) + } + "MP_GetAuth" if lowercase.contains("authenticate failed terminal") => { + Some((SccmManagementPointPhase::Authenticate, failed, true)) + } + "MP_CliReg" | "MP_RegistrationManager" + if lowercase.contains("register or identify succeeded") => + { + Some(( + SccmManagementPointPhase::RegisterOrIdentify, + succeeded, + false, + )) + } + "MP_CliReg" | "MP_RegistrationManager" + if lowercase.contains("register or identify failed terminal") => + { + Some((SccmManagementPointPhase::RegisterOrIdentify, failed, true)) + } + "MP_Location" if lowercase.contains("resolve location succeeded") => Some(( + SccmManagementPointPhase::ResolveLocationOrPolicy, + succeeded, + false, + )), + "MP_Location" if lowercase.contains("resolve location failed terminal") => Some(( + SccmManagementPointPhase::ResolveLocationOrPolicy, + failed, + true, + )), + "MP_GetPolicy" if lowercase.contains("resolve policy succeeded") => Some(( + SccmManagementPointPhase::ResolveLocationOrPolicy, + succeeded, + false, + )), + "MP_GetPolicy" if lowercase.contains("resolve policy failed terminal") => Some(( + SccmManagementPointPhase::ResolveLocationOrPolicy, + failed, + true, + )), + "MP_GetPolicy" if lowercase.contains("respond deferred") => { + Some((SccmManagementPointPhase::Respond, deferred, false)) + } + "MP_GetPolicy" if lowercase.contains("respond succeeded") => { + Some((SccmManagementPointPhase::Respond, succeeded, false)) + } + "MP_GetPolicy" if lowercase.contains("respond failed terminal") => { + Some((SccmManagementPointPhase::Respond, failed, true)) + } + "MP_GetPolicy" if lowercase.contains("record outcome succeeded") => { + Some((SccmManagementPointPhase::RecordOutcome, succeeded, false)) + } + "MP_GetPolicy" if lowercase.contains("record outcome failed terminal") => { + Some((SccmManagementPointPhase::RecordOutcome, failed, true)) + } + _ => None, + } +} + +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 lowercase_name = source.artifact.display_name.to_ascii_lowercase(); + match (source.source_group.as_str(), source.producer.as_str()) { + (MP_AUTH_GROUP, "MP_GetAuth") => supported_basename(&lowercase_name, "mp_getauth"), + (MP_AUTH_GROUP, "MP_CliReg") => supported_basename(&lowercase_name, "mp_clireg"), + (MP_AUTH_GROUP, "MP_RegistrationManager") => { + supported_basename(&lowercase_name, "mp_registrationmanager") + } + (MP_POLICY_GROUP, "MP_GetPolicy") => supported_basename(&lowercase_name, "mp_getpolicy"), + (MP_POLICY_GROUP, "MP_Location") => supported_basename(&lowercase_name, "mp_location"), + _ => false, + } +} + +fn supported_basename(name: &str, stem: &str) -> bool { + name == format!("{stem}.log") + || name == format!("{stem}.lo_") + || name + .strip_prefix(&format!("{stem}.log.")) + .is_some_and(|suffix| { + !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +fn is_supplemental_source(source: &SccmManagementPointSource) -> bool { + source.source_group == MP_IIS_GROUP + || (source.source_group == MP_POLICY_GROUP + && source.producer == "SMS_MP_CONTROL_MANAGER" + && source + .artifact + .display_name + .eq_ignore_ascii_case("mpcontrol.log")) +} + +fn token_value(message: &str, label: &str) -> Option { + let lowercase = message.to_ascii_lowercase(); + let needle = format!("{}=", label.to_ascii_lowercase()); + let start = lowercase.find(&needle)? + needle.len(); + let remainder = &message[start..]; + let (value, _) = if let Some(braced) = remainder.strip_prefix('{') { + let end = braced.find('}')?; + (&braced[..end], end + 2) + } else { + let end = remainder + .find(|character: char| { + character.is_whitespace() || matches!(character, ',' | ';' | '&') + }) + .unwrap_or(remainder.len()); + (&remainder[..end], end) + }; + (!value.is_empty()).then(|| value.to_owned()) +} + +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(), + }); + valid.then(|| value.to_ascii_lowercase()) +} + +fn has_nonzero_result(message: &str) -> bool { + let Some(value) = token_value(message, "Result") else { + return false; + }; + let Some(hex) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + else { + return false; + }; + hex.len() == 8 + && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) + && u32::from_str_radix(hex, 16).is_ok_and(|value| value != 0) +} + +fn valid_site_code(value: &str) -> bool { + value.len() == 3 && value.bytes().all(|byte| byte.is_ascii_alphanumeric()) +} + +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 + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':')) +} + +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 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 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 ranges: BTreeMap = BTreeMap::new(); + for reference in references { + let (Some(start), Some(end)) = (reference.line_start, reference.line_end) else { + continue; + }; + ranges + .entry(reference.artifact_id) + .and_modify(|range| { + range.0 = range.0.min(start); + range.1 = range.1.max(end); + }) + .or_insert((start, end)); + } + ranges + .into_iter() + .map(|(artifact_id, (line_start, line_end))| SccmEvidenceRef { + entry_id: format!("{artifact_id}:{line_start}-{line_end}"), + artifact_id, + line_start: Some(line_start), + line_end: Some(line_end), + }) + .collect() +} + +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()); +} + +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 evidence_by_id = bundle + .evidence + .iter() + .map(|evidence| (evidence.reference.entry_id.as_str(), evidence)) + .collect::>(); + let unrelated = rejected.iter().any(|reference| { + evidence_by_id + .get(reference.entry_id.as_str()) + .is_some_and(|evidence| { + token_value(&evidence.message, "RequestId").is_none() + && (token_value(&evidence.message, "AssignmentId").is_some() + || token_value(&evidence.message, "ClientId").is_some()) + }) + }); + let rotation = bundle + .sources + .iter() + .any(|source| source.fragment_complete == Some(false)); + let (observation_id, finding_id, phase, classification, group, reason) = if unrelated { + ( + "observation:unrelated-client-like-key", + "finding:mp-unrelated-client-like-key", + SccmManagementPointPhase::ResolveLocationOrPolicy, + SccmManagementPointClassification::IncompatibleKey, + MP_AUTH_GROUP, + "Capture bounded MP_GetAuth.log evidence with the exact versioned request key.", + ) + } else if rotation { + ( + "observation:rotation-malformed", + "finding:mp-rotation-malformed", + SccmManagementPointPhase::ReceiveRequest, + SccmManagementPointClassification::LowConfidenceSymptom, + MP_AUTH_GROUP, + "Collect a supported-version MP_GetAuth.log record containing a complete exact request key.", + ) + } else { + ( + "observation:mp-malformed", + "finding:mp-malformed", + SccmManagementPointPhase::ReceiveRequest, + SccmManagementPointClassification::LowConfidenceSymptom, + MP_AUTH_GROUP, + "Collect bounded MP_GetAuth.log evidence under the validated extraction profile.", + ) + }; + let request = workflow_request_for_group(group, reason); + let evidence = merge_references(rejected.clone()); + observations.push(SccmManagementPointSourceLocalObservation { + observation_id: observation_id.to_owned(), + 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( + finding_id, + observation_id, + phase, + classification, + evidence, + None, + &[request], + ) { + findings.push(finding); + } +} + +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_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: if group == MP_AUTH_GROUP { + SccmManagementPointPhase::ReceiveRequest + } else { + SccmManagementPointPhase::ResolveLocationOrPolicy + }, + 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, + if group == MP_AUTH_GROUP { + SccmManagementPointPhase::ReceiveRequest + } else { + SccmManagementPointPhase::ResolveLocationOrPolicy + }, + 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) + .map(|source| { + if source.artifact.coverage == SccmCoverageState::Captured + && source.fragment_complete != Some(true) + { + 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; + } + } + 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 Vec, + observations: &mut Vec, + findings: &mut Vec, + coverage_gaps: &mut Vec, + counterpart_facts: &mut Vec, +) { + 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/mod.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs new file mode 100644 index 000000000..77ff4eb0d --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs @@ -0,0 +1,3 @@ +mod management_point; + +pub use management_point::*; diff --git a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs index 67ca1f4b3..2b7b051ed 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -4,8 +4,8 @@ use std::path::{Path, PathBuf}; use cmtraceopen_parser::sccm::{ analyze_management_point, declared_source_catalog, normalize_ccm_artifact, SccmArtifact, - SccmArtifactFamily, SccmCoverageState, SccmManagementPointBundle, - SccmManagementPointSource, SccmManagementPointTopology, SccmRole, SccmRotation, + SccmArtifactFamily, SccmCoverageState, SccmManagementPointBundle, SccmManagementPointSource, + SccmManagementPointTopology, SccmRole, SccmRotation, }; use serde::Deserialize; use serde_json::{json, Value}; @@ -161,11 +161,7 @@ fn load_bundle(scenario: &str) -> SccmManagementPointBundle { }); } - sources.sort_by(|left, right| { - left.artifact - .artifact_id - .cmp(&right.artifact.artifact_id) - }); + 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 { @@ -545,8 +541,7 @@ fn management_point_counterpart_handoff_requires_an_exact_policy_key() { "{scenario}: policy counterpart fact needs a policy ID" ); assert_eq!( - fact["key"]["extractionProfileId"], - "mp-server-5.00.test-v1", + fact["key"]["extractionProfileId"], "mp-server-5.00.test-v1", "{scenario}" ); assert!( @@ -556,9 +551,10 @@ fn management_point_counterpart_handoff_requires_an_exact_policy_key() { } } - let unrelated = - serde_json::to_value(analyze_management_point(&load_bundle("unrelated-client-like-key"))) - .unwrap(); + let unrelated = serde_json::to_value(analyze_management_point(&load_bundle( + "unrelated-client-like-key", + ))) + .unwrap(); assert!( unrelated["counterpartReadyFacts"] .as_array() @@ -588,6 +584,9 @@ fn management_point_catalog_declares_every_reducer_source() { ("mpcontrol.log", "mpcontrol"), ] { let expected = (expected.0.to_owned(), expected.1.to_owned()); - assert!(sources.contains(&expected), "missing MP source {expected:?}"); + assert!( + sources.contains(&expected), + "missing MP source {expected:?}" + ); } } From 24ab957e43463b6fb20dd18a5f34a324a1ad1efa Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 07:42:41 -0400 Subject: [PATCH 125/422] test(sccm): reproduce provider review blockers --- .../admin-service-access-denied/expected.json | 28 ++ .../admin-service-access-denied/manifest.json | 44 +++ .../current/AdminService.log | 1 + .../admin-service-parse-failed/expected.json | 29 ++ .../admin-service-parse-failed/manifest.json | 52 ++++ .../admin-service-skipped/expected.json | 28 ++ .../admin-service-skipped/manifest.json | 44 +++ .../current/AdminService.log | 2 + .../blocked-deferred/expected.json | 78 ++++++ .../blocked-deferred/manifest.json | 52 ++++ .../provider-source-absent/expected.json | 28 ++ .../provider-source-absent/manifest.json | 44 +++ .../provider-local/current/Smsprov.log | 1 + .../provider-source-capped/expected.json | 29 ++ .../provider-source-capped/manifest.json | 52 ++++ .../provider-source-unsupported/expected.json | 28 ++ .../provider-source-unsupported/manifest.json | 44 +++ ...ider_and_admin_service_fixture_contract.rs | 260 +++++++++++++++++- .../tests/sccm_spine_contract.rs | 10 + 19 files changed, 852 insertions(+), 2 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-access-denied/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-access-denied/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/evidence/server-admin-service/admin-service-lab/current/AdminService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-skipped/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-skipped/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/evidence/server-admin-service/admin-service-lab/current/AdminService.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-absent/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-absent/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/evidence/server-provider/provider-local/current/Smsprov.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-unsupported/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-unsupported/manifest.json 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..3ee0e6037 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-access-denied/expected.json @@ -0,0 +1,28 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "providerAndAdminService", + "scenario": "admin-service-access-denied", + "profiles": [ + { + "layer": "adminService", + "selectionState": "unknownVersion" + } + ], + "coverage": [ + { + "artifactId": "coverage-admin-access-denied", + "state": "accessDenied", + "sourceId": "server-admin-service", + "layer": "adminService" + } + ], + "transactions": [], + "sourceLocalObservations": [], + "artifactRequests": [ + { + "logicalArtifactId": "server-admin-service", + "reason": "Capture bounded Admin Service evidence after access is authorized; access-denied coverage is not an outcome." + } + ], + "crossSideCausalClaims": [] +} 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..ba6878a48 --- /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, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "admin-service-access-denied", + "bundle": { + "bundleRole": "server", + "workflow": "providerAndAdminService", + "capturedUtc": "2026-07-31T11:10:00Z" + }, + "topology": { + "siteCode": "LAB", + "rolesObserved": ["provider"], + "endpoints": [ + { + "endpointId": "admin-service-lab", + "layer": "adminService", + "hostHandle": "safe:server:lab-provider-01", + "producerRole": "provider" + } + ] + }, + "artifacts": [ + { + "artifactId": "coverage-admin-access-denied", + "sourceId": "server-admin-service", + "producerRole": "provider", + "layer": "adminService", + "producerHostHandle": "safe:server:lab-provider-01", + "endpointId": "admin-service-lab", + "diagnosticUse": "primary", + "originalBasename": "AdminService.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/AdminService.log", + "pathFingerprint": "synthetic:admin-access-denied", + "rotation": { + "kind": "current", + "lineageId": "admin-access-denied" + }, + "captureState": "accessDenied", + "sourceVersion": "5.00.UNKNOWN", + "collectedUtc": "2026-07-31T11:10:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/evidence/server-admin-service/admin-service-lab/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/evidence/server-admin-service/admin-service-lab/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/server-admin-service/admin-service-lab/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..71802e335 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/expected.json @@ -0,0 +1,29 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "providerAndAdminService", + "scenario": "admin-service-parse-failed", + "profiles": [ + { + "layer": "adminService", + "selectionState": "selectedSynthetic", + "profileId": "admin-service-server-5.00.test-v1" + } + ], + "coverage": [ + { + "artifactId": "coverage-admin-parse-failed", + "state": "parseFailed", + "sourceId": "server-admin-service", + "layer": "adminService" + } + ], + "transactions": [], + "sourceLocalObservations": [], + "artifactRequests": [ + { + "logicalArtifactId": "server-admin-service", + "reason": "Recapture or repair the bounded Admin Service source; malformed evidence is coverage, not an outcome." + } + ], + "crossSideCausalClaims": [] +} 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..fb5cf5772 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/manifest.json @@ -0,0 +1,52 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "admin-service-parse-failed", + "bundle": { + "bundleRole": "server", + "workflow": "providerAndAdminService", + "capturedUtc": "2026-07-31T11:20:00Z" + }, + "topology": { + "siteCode": "LAB", + "rolesObserved": ["provider"], + "endpoints": [ + { + "endpointId": "admin-service-lab", + "layer": "adminService", + "hostHandle": "safe:server:lab-provider-01", + "producerRole": "provider" + } + ] + }, + "artifacts": [ + { + "artifactId": "coverage-admin-parse-failed", + "sourceId": "server-admin-service", + "producerRole": "provider", + "layer": "adminService", + "producerHostHandle": "safe:server:lab-provider-01", + "endpointId": "admin-service-lab", + "diagnosticUse": "primary", + "originalBasename": "AdminService.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/AdminService.log", + "pathFingerprint": "synthetic:admin-parse-failed", + "rotation": { + "kind": "current", + "lineageId": "admin-parse-failed", + "fragmentComplete": true + }, + "captureState": "parseFailed", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-31T11:20:00Z", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "bytesCopied": 64, + "relativePath": "evidence/server-admin-service/admin-service-lab/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..5667c6d79 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-skipped/expected.json @@ -0,0 +1,28 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "providerAndAdminService", + "scenario": "admin-service-skipped", + "profiles": [ + { + "layer": "adminService", + "selectionState": "unknownVersion" + } + ], + "coverage": [ + { + "artifactId": "coverage-admin-skipped", + "state": "skipped", + "sourceId": "server-admin-service", + "layer": "adminService" + } + ], + "transactions": [], + "sourceLocalObservations": [], + "artifactRequests": [ + { + "logicalArtifactId": "server-admin-service", + "reason": "Capture the bounded Admin Service source; skipped coverage does not establish a workflow outcome." + } + ], + "crossSideCausalClaims": [] +} 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..28ad35b1d --- /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, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "admin-service-skipped", + "bundle": { + "bundleRole": "server", + "workflow": "providerAndAdminService", + "capturedUtc": "2026-07-31T11:30:00Z" + }, + "topology": { + "siteCode": "LAB", + "rolesObserved": ["provider"], + "endpoints": [ + { + "endpointId": "admin-service-lab", + "layer": "adminService", + "hostHandle": "safe:server:lab-provider-01", + "producerRole": "provider" + } + ] + }, + "artifacts": [ + { + "artifactId": "coverage-admin-skipped", + "sourceId": "server-admin-service", + "producerRole": "provider", + "layer": "adminService", + "producerHostHandle": "safe:server:lab-provider-01", + "endpointId": "admin-service-lab", + "diagnosticUse": "primary", + "originalBasename": "AdminService.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/AdminService.log", + "pathFingerprint": "synthetic:admin-skipped", + "rotation": { + "kind": "current", + "lineageId": "admin-skipped" + }, + "captureState": "skipped", + "sourceVersion": "5.00.UNKNOWN", + "collectedUtc": "2026-07-31T11:30:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/evidence/server-admin-service/admin-service-lab/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/evidence/server-admin-service/admin-service-lab/current/AdminService.log new file mode 100644 index 000000000..7147007ae --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/evidence/server-admin-service/admin-service-lab/current/AdminService.log @@ -0,0 +1,2 @@ + + 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..09699a682 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/expected.json @@ -0,0 +1,78 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "providerAndAdminService", + "scenario": "blocked-deferred", + "profiles": [ + { + "layer": "adminService", + "selectionState": "selectedSynthetic", + "profileId": "admin-service-server-5.00.test-v1" + } + ], + "coverage": [ + { + "artifactId": "blocked-deferred-admin-current", + "state": "captured", + "sourceId": "server-admin-service", + "layer": "adminService" + } + ], + "transactions": [ + { + "transactionId": "adminService:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee:safe-operation-admin-deferred:admin-service-lab", + "layer": "adminService", + "key": { + "requestId": "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", + "operationHandle": "safe-operation-admin-deferred", + "endpointId": "admin-service-lab", + "confidence": "exact", + "extractionProfileId": "admin-service-server-5.00.test-v1" + }, + "topologyCompatibility": "exact", + "timestampOrdering": "usable", + "state": "incomplete", + "classification": "insufficientEvidence", + "confidence": "low", + "confidenceCeiling": "low", + "terminalEvidence": false, + "coverageGapArtifactIds": [], + "publicSummary": "Admin Service evidence records a blocked or deferred request without a terminal outcome.", + "observations": [ + { + "observationId": "blocked-deferred-01-receive", + "phase": "receive", + "disposition": "succeeded", + "terminal": false, + "evidence": [ + { + "artifactId": "blocked-deferred-admin-current", + "startLine": 1, + "endLine": 1 + } + ] + }, + { + "observationId": "blocked-deferred-02-deferred", + "phase": "route", + "disposition": "pending", + "terminal": false, + "evidence": [ + { + "artifactId": "blocked-deferred-admin-current", + "startLine": 2, + "endLine": 2 + } + ] + } + ] + } + ], + "sourceLocalObservations": [], + "artifactRequests": [ + { + "logicalArtifactId": "server-admin-service", + "reason": "Capture the bounded Admin Service source lineage through an explicit terminal outcome after the blocked or deferred phase." + } + ], + "crossSideCausalClaims": [] +} 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..a312af821 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/manifest.json @@ -0,0 +1,52 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "blocked-deferred", + "bundle": { + "bundleRole": "server", + "workflow": "providerAndAdminService", + "capturedUtc": "2026-07-31T11:40:00Z" + }, + "topology": { + "siteCode": "LAB", + "rolesObserved": ["provider"], + "endpoints": [ + { + "endpointId": "admin-service-lab", + "layer": "adminService", + "hostHandle": "safe:server:lab-provider-01", + "producerRole": "provider" + } + ] + }, + "artifacts": [ + { + "artifactId": "blocked-deferred-admin-current", + "sourceId": "server-admin-service", + "producerRole": "provider", + "layer": "adminService", + "producerHostHandle": "safe:server:lab-provider-01", + "endpointId": "admin-service-lab", + "diagnosticUse": "primary", + "originalBasename": "AdminService.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/AdminService.log", + "pathFingerprint": "synthetic:blocked-deferred-admin", + "rotation": { + "kind": "current", + "lineageId": "admin-blocked-deferred", + "fragmentComplete": true + }, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-31T11:40:00Z", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "bytesCopied": 826, + "relativePath": "evidence/server-admin-service/admin-service-lab/current/AdminService.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..f3ac5fd89 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-absent/expected.json @@ -0,0 +1,28 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "providerAndAdminService", + "scenario": "provider-source-absent", + "profiles": [ + { + "layer": "provider", + "selectionState": "unknownVersion" + } + ], + "coverage": [ + { + "artifactId": "coverage-provider-absent", + "state": "absent", + "sourceId": "server-provider", + "layer": "provider" + } + ], + "transactions": [], + "sourceLocalObservations": [], + "artifactRequests": [ + { + "logicalArtifactId": "server-provider", + "reason": "Capture the bounded Provider source; absent coverage does not establish a workflow outcome." + } + ], + "crossSideCausalClaims": [] +} 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..e6a53d795 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-absent/manifest.json @@ -0,0 +1,44 @@ +{ + "sccmManifestVersion": 1, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "provider-source-absent", + "bundle": { + "bundleRole": "server", + "workflow": "providerAndAdminService", + "capturedUtc": "2026-07-31T11:50:00Z" + }, + "topology": { + "siteCode": "LAB", + "rolesObserved": ["provider"], + "endpoints": [ + { + "endpointId": "provider-local", + "layer": "provider", + "hostHandle": "safe:server:lab-provider-01", + "producerRole": "provider" + } + ] + }, + "artifacts": [ + { + "artifactId": "coverage-provider-absent", + "sourceId": "server-provider", + "producerRole": "provider", + "layer": "provider", + "producerHostHandle": "safe:server:lab-provider-01", + "endpointId": "provider-local", + "diagnosticUse": "primary", + "originalBasename": "Smsprov.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/Smsprov.log", + "pathFingerprint": "synthetic:provider-absent", + "rotation": { + "kind": "current", + "lineageId": "provider-absent" + }, + "captureState": "absent", + "sourceVersion": "5.00.UNKNOWN", + "collectedUtc": "2026-07-31T11:50:00Z" + } + ] +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/evidence/server-provider/provider-local/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/evidence/server-provider/provider-local/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/server-provider/provider-local/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..7cd076a40 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/expected.json @@ -0,0 +1,29 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "providerAndAdminService", + "scenario": "provider-source-capped", + "profiles": [ + { + "layer": "provider", + "selectionState": "selectedSynthetic", + "profileId": "provider-server-5.00.test-v1" + } + ], + "coverage": [ + { + "artifactId": "coverage-provider-capped", + "state": "capped", + "sourceId": "server-provider", + "layer": "provider" + } + ], + "transactions": [], + "sourceLocalObservations": [], + "artifactRequests": [ + { + "logicalArtifactId": "server-provider", + "reason": "Recapture the bounded Provider source without truncating the required transaction evidence." + } + ], + "crossSideCausalClaims": [] +} 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..a96170c54 --- /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, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "provider-source-capped", + "bundle": { + "bundleRole": "server", + "workflow": "providerAndAdminService", + "capturedUtc": "2026-07-31T12:00:00Z" + }, + "topology": { + "siteCode": "LAB", + "rolesObserved": ["provider"], + "endpoints": [ + { + "endpointId": "provider-local", + "layer": "provider", + "hostHandle": "safe:server:lab-provider-01", + "producerRole": "provider" + } + ] + }, + "artifacts": [ + { + "artifactId": "coverage-provider-capped", + "sourceId": "server-provider", + "producerRole": "provider", + "layer": "provider", + "producerHostHandle": "safe:server:lab-provider-01", + "endpointId": "provider-local", + "diagnosticUse": "primary", + "originalBasename": "Smsprov.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/Smsprov.log", + "pathFingerprint": "synthetic:provider-capped", + "rotation": { + "kind": "current", + "lineageId": "provider-capped", + "fragmentComplete": false + }, + "captureState": "capped", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-31T12:00:00Z", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 398, + "limitApplied": true + }, + "bytesCopied": 398, + "relativePath": "evidence/server-provider/provider-local/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..4c8b67f93 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-unsupported/expected.json @@ -0,0 +1,28 @@ +{ + "contractState": "proposedPendingReviewed318And335", + "workflow": "providerAndAdminService", + "scenario": "provider-source-unsupported", + "profiles": [ + { + "layer": "provider", + "selectionState": "unknownVersion" + } + ], + "coverage": [ + { + "artifactId": "coverage-provider-unsupported", + "state": "unsupported", + "sourceId": "server-provider", + "layer": "provider" + } + ], + "transactions": [], + "sourceLocalObservations": [], + "artifactRequests": [ + { + "logicalArtifactId": "server-provider", + "reason": "Use a supported versioned Provider source profile before evaluating request outcomes." + } + ], + "crossSideCausalClaims": [] +} 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..e9d5f930e --- /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, + "proposalOnly": true, + "syntheticFixture": true, + "scenario": "provider-source-unsupported", + "bundle": { + "bundleRole": "server", + "workflow": "providerAndAdminService", + "capturedUtc": "2026-07-31T12:10:00Z" + }, + "topology": { + "siteCode": "LAB", + "rolesObserved": ["provider"], + "endpoints": [ + { + "endpointId": "provider-local", + "layer": "provider", + "hostHandle": "safe:server:lab-provider-01", + "producerRole": "provider" + } + ] + }, + "artifacts": [ + { + "artifactId": "coverage-provider-unsupported", + "sourceId": "server-provider", + "producerRole": "provider", + "layer": "provider", + "producerHostHandle": "safe:server:lab-provider-01", + "endpointId": "provider-local", + "diagnosticUse": "primary", + "originalBasename": "Smsprov.log", + "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/Smsprov.log", + "pathFingerprint": "synthetic:provider-unsupported", + "rotation": { + "kind": "current", + "lineageId": "provider-unsupported" + }, + "captureState": "unsupported", + "sourceVersion": "5.00.UNKNOWN", + "collectedUtc": "2026-07-31T12:10:00Z" + } + ] +} 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 index 3b54702ee..2c2abf1e0 100644 --- 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 @@ -9,10 +9,14 @@ use cmtraceopen_parser::sccm::{ }; use serde_json::Value; -const SCENARIOS: [&str; 13] = [ +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", @@ -20,6 +24,9 @@ const SCENARIOS: [&str; 13] = [ "provider-authz-denied", "provider-query-failure", "provider-retry", + "provider-source-absent", + "provider-source-capped", + "provider-source-unsupported", "provider-success", "provider-timeout", "rotation-boundary", @@ -355,6 +362,9 @@ fn expected_transaction_ids(scenario: &str) -> &'static [&'static str] { "admin-service-success" => &[ "adminService:55555555-5555-5555-5555-555555555555:safe-operation-admin-read:admin-service-lab", ], + "blocked-deferred" => &[ + "adminService:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee:safe-operation-admin-deferred:admin-service-lab", + ], "contradictory-evidence" => &[ "provider:dddddddd-dddd-dddd-dddd-dddddddddddd:safe-operation-contradictory:provider-local", ], @@ -410,6 +420,7 @@ fn expected_outcomes( "provider-timeout" | "incomplete" => { &[("incomplete", "insufficientEvidence", "low", "low")] } + "blocked-deferred" => &[("incomplete", "insufficientEvidence", "low", "low")], "rotation-boundary" => &[], _ => &[], } @@ -424,6 +435,9 @@ fn expected_public_summaries(scenario: &str) -> &'static [&'static str] { "admin-service-success" => { &["Admin Service request completed with explicit terminal evidence."] } + "blocked-deferred" => { + &["Admin Service evidence records a blocked or deferred request without a terminal outcome."] + } "contradictory-evidence" => &[ "Provider evidence contains contradictory terminal outcomes for one exact request key.", ], @@ -447,9 +461,13 @@ fn expected_public_summaries(scenario: &str) -> &'static [&'static str] { fn expected_artifact_ids(scenario: &str) -> &'static [&'static str] { match scenario { + "admin-service-access-denied" => &["coverage-admin-access-denied"], "admin-service-auth-failure" => &["admin-auth-current"], "admin-service-backend-failure" => &["admin-backend-current"], + "admin-service-parse-failed" => &["coverage-admin-parse-failed"], + "admin-service-skipped" => &["coverage-admin-skipped"], "admin-service-success" => &["admin-success-current"], + "blocked-deferred" => &["blocked-deferred-admin-current"], "contradictory-evidence" => &["contradictory-provider-current"], "iis-supplemental" => &["admin-iis-current", "iis-supplemental-current"], "incomplete" => &["incomplete-admin-current"], @@ -457,6 +475,9 @@ fn expected_artifact_ids(scenario: &str) -> &'static [&'static str] { "provider-authz-denied" => &["provider-authz-current"], "provider-query-failure" => &["provider-query-current"], "provider-retry" => &["provider-retry-current"], + "provider-source-absent" => &["coverage-provider-absent"], + "provider-source-capped" => &["coverage-provider-capped"], + "provider-source-unsupported" => &["coverage-provider-unsupported"], "provider-success" => &["provider-success-current"], "provider-timeout" => &["provider-timeout-current"], "rotation-boundary" => &["rotation-01-current", "rotation-02-lo"], @@ -486,6 +507,10 @@ fn expected_observation_ids(scenario: &str) -> &'static [&'static str] { "admin-success-05-respond", "admin-success-06-outcome", ], + "blocked-deferred" => &[ + "blocked-deferred-01-receive", + "blocked-deferred-02-deferred", + ], "contradictory-evidence" => &[ "contradictory-01-receive", "contradictory-02-authorize", @@ -570,6 +595,22 @@ fn expected_source_local_reasons(scenario: &str) -> &'static [&'static str] { fn expected_artifact_requests(scenario: &str) -> &'static [(&'static str, &'static str)] { match scenario { + "admin-service-access-denied" => &[( + "server-admin-service", + "Capture bounded Admin Service evidence after access is authorized; access-denied coverage is not an outcome.", + )], + "admin-service-parse-failed" => &[( + "server-admin-service", + "Recapture or repair the bounded Admin Service source; malformed evidence is coverage, not an outcome.", + )], + "admin-service-skipped" => &[( + "server-admin-service", + "Capture the bounded Admin Service source; skipped coverage does not establish a workflow outcome.", + )], + "blocked-deferred" => &[( + "server-admin-service", + "Capture the bounded Admin Service source lineage through an explicit terminal outcome after the blocked or deferred phase.", + )], "incomplete" => &[( "server-admin-service", "Capture the bounded Admin Service source lineage for the exact request key and terminal outcome.", @@ -578,6 +619,18 @@ fn expected_artifact_requests(scenario: &str) -> &'static [(&'static str, &'stat "server-provider", "Capture the bounded Provider source lineage for the exact request key and terminal outcome.", )], + "provider-source-absent" => &[( + "server-provider", + "Capture the bounded Provider source; absent coverage does not establish a workflow outcome.", + )], + "provider-source-capped" => &[( + "server-provider", + "Recapture the bounded Provider source without truncating the required transaction evidence.", + )], + "provider-source-unsupported" => &[( + "server-provider", + "Use a supported versioned Provider source profile before evaluating request outcomes.", + )], "contradictory-evidence" => &[( "server-provider", "Capture the bounded Provider source lineage for the exact request key and reconcile contradictory terminal outcomes.", @@ -592,9 +645,13 @@ fn expected_artifact_requests(scenario: &str) -> &'static [(&'static str, &'stat fn expected_profile_layers(scenario: &str) -> &'static [&'static str] { match scenario { - "admin-service-auth-failure" + "admin-service-access-denied" + | "admin-service-auth-failure" | "admin-service-backend-failure" + | "admin-service-parse-failed" + | "admin-service-skipped" | "admin-service-success" + | "blocked-deferred" | "iis-supplemental" | "incomplete" => &["adminService"], "privacy-redaction" => &["adminService", "provider"], @@ -602,6 +659,9 @@ fn expected_profile_layers(scenario: &str) -> &'static [&'static str] { | "contradictory-evidence" | "provider-query-failure" | "provider-retry" + | "provider-source-absent" + | "provider-source-capped" + | "provider-source-unsupported" | "provider-success" | "provider-timeout" | "rotation-boundary" => &["provider"], @@ -1636,6 +1696,23 @@ fn provider_and_admin_service_scenario_matrix_is_exact() { assert_eq!(actual, expected); } +#[test] +fn preparation_state_records_reviewed_dependencies_as_available() { + let mut stale = Vec::new(); + for scenario in SCENARIOS { + let expected = + read_json(scenario, "expected.json").unwrap_or_else(|error| panic!("{error}")); + if expected["contractState"] != "preparationOnlyReviewedDependenciesAvailable" { + stale.push(scenario); + } + } + + assert!( + stale.is_empty(), + "preparation outputs still claim reviewed #318/#335 dependencies are pending: {stale:#?}" + ); +} + #[test] fn provider_and_admin_service_sources_are_layered_and_role_exact() { let provider = classify_artifact_name("Smsprov.log", SccmRole::Provider); @@ -1982,6 +2059,80 @@ fn privacy_iis_and_missing_source_controls_stay_conservative() { ); } +#[test] +fn noncaptured_malformed_and_blocked_deferred_coverage_are_not_outcomes() { + let coverage_cases = [ + ( + "admin-service-access-denied", + "accessDenied", + "adminService", + "server-admin-service", + ), + ( + "admin-service-parse-failed", + "parseFailed", + "adminService", + "server-admin-service", + ), + ( + "admin-service-skipped", + "skipped", + "adminService", + "server-admin-service", + ), + ( + "provider-source-absent", + "absent", + "provider", + "server-provider", + ), + ( + "provider-source-capped", + "capped", + "provider", + "server-provider", + ), + ( + "provider-source-unsupported", + "unsupported", + "provider", + "server-provider", + ), + ]; + + for (scenario, state, layer, request) in coverage_cases { + let expected = read_json(scenario, "expected.json").unwrap(); + assert_eq!(expected["coverage"][0]["state"], state, "{scenario}"); + assert_eq!(expected["coverage"][0]["layer"], layer, "{scenario}"); + assert_eq!( + expected["transactions"], + serde_json::json!([]), + "{scenario}" + ); + assert_eq!( + expected["artifactRequests"][0]["logicalArtifactId"], request, + "{scenario}" + ); + assert_eq!( + expected["crossSideCausalClaims"], + serde_json::json!([]), + "{scenario}" + ); + } + + let blocked = read_json("blocked-deferred", "expected.json").unwrap(); + let transaction = &blocked["transactions"][0]; + assert_eq!(transaction["state"], "incomplete"); + assert_eq!(transaction["classification"], "insufficientEvidence"); + assert_eq!(transaction["confidence"], "low"); + assert_eq!(transaction["terminalEvidence"], false); + assert_eq!(transaction["observations"][1]["disposition"], "pending"); + assert_eq!( + blocked["artifactRequests"][0]["logicalArtifactId"], + "server-admin-service" + ); +} + #[test] fn schema_and_identity_mutations_fail_closed() { let manifest = read_json("provider-success", "manifest.json").unwrap(); @@ -2628,6 +2779,111 @@ fn provider_retry_requires_an_explicit_retryable_failure_then_recovery() { ); } +#[test] +fn high_success_requires_every_layer_phase_in_order() { + let cases = [ + ( + "provider-success", + 1_usize, + "authenticateOrAuthorize", + "receive", + ), + ( + "admin-service-success", + 2_usize, + "route", + "authenticateOrAuthorize", + ), + ]; + let mut accepted = Vec::new(); + + for (scenario, observation_index, required_phase, replacement_phase) in cases { + let manifest = read_json(scenario, "manifest.json").unwrap(); + let mut expected = read_json(scenario, "expected.json").unwrap(); + let mut records = normalized_records(scenario, &manifest); + assert!( + schema_failures_with_records(scenario, &manifest, &expected, &records).is_empty(), + "{scenario}: baseline contract is invalid" + ); + + let observation = &mut expected["transactions"][0]["observations"][observation_index]; + observation["phase"] = Value::String(replacement_phase.to_owned()); + let artifact_id = observation["evidence"][0]["artifactId"] + .as_str() + .unwrap() + .to_owned(); + let start_line = + u32::try_from(observation["evidence"][0]["startLine"].as_u64().unwrap()).unwrap(); + let end_line = + u32::try_from(observation["evidence"][0]["endLine"].as_u64().unwrap()).unwrap(); + let record = records + .get_mut(&(artifact_id, start_line, end_line)) + .expect("paired logical record exists"); + record.message = record.message.replacen( + &format!("Phase={required_phase}"), + &format!("Phase={replacement_phase}"), + 1, + ); + + if schema_failures_with_records(scenario, &manifest, &expected, &records).is_empty() { + accepted.push(scenario); + } + } + + assert!( + accepted.is_empty(), + "paired high-success records omitted required phases: {accepted:#?}" + ); +} + +#[test] +fn provider_retry_recovery_is_bound_to_the_failed_phase() { + let scenario = "provider-retry"; + let manifest = read_json(scenario, "manifest.json").unwrap(); + let mut expected = read_json(scenario, "expected.json").unwrap(); + let mut records = normalized_records(scenario, &manifest); + assert!( + schema_failures_with_records(scenario, &manifest, &expected, &records).is_empty(), + "provider-retry baseline contract is invalid" + ); + + let observation = &mut expected["transactions"][0]["observations"][0]; + observation["disposition"] = Value::String("retryableFailure".to_owned()); + let artifact_id = observation["evidence"][0]["artifactId"] + .as_str() + .unwrap() + .to_owned(); + let start_line = + u32::try_from(observation["evidence"][0]["startLine"].as_u64().unwrap()).unwrap(); + let end_line = u32::try_from(observation["evidence"][0]["endLine"].as_u64().unwrap()).unwrap(); + let record = records + .get_mut(&(artifact_id, start_line, end_line)) + .expect("paired retry logical record exists"); + record.message = + record + .message + .replacen("Disposition=succeeded", "Disposition=retryableFailure", 1); + + assert!( + !schema_failures_with_records(scenario, &manifest, &expected, &records).is_empty(), + "an unrecovered receive-phase retryable failure retained terminal high success" + ); +} + +#[test] +fn rejected_fixture_segment_diagnostic_omits_raw_value() { + let sensitive = "private-credential-value"; + let message = format!( + "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=receive; AuthorizationHeader {sensitive}" + ); + let error = parse_fixture_fields(&message).expect_err("malformed fixture segment is rejected"); + + assert!( + !error.contains(sensitive), + "rejected fixture diagnostic echoed a raw private value: {error}" + ); +} + #[test] fn provider_retry_scenario_is_explicit() { read_json("provider-retry", "manifest.json").expect("provider retry manifest exists"); diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 0ec1d6503..711c40b56 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -4647,6 +4647,16 @@ fn evidence_public_message_projection_redacts_provider_handles_in_all_positions( "private-auth-tail", None, ), + ( + "AuthorizationHeader=Custom private-credential-value; status=81", + "private-credential-value", + Some("status=81"), + ), + ( + "QueryHandle=SELECT 1; DROP TABLE private_object; status=80", + "DROP TABLE private_object", + Some("status=80"), + ), ]; for (raw_message, sensitive, safe_tail) in cases { From 270b926b106111f315b92f0ac0fc7b1989676064 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 07:43:23 -0400 Subject: [PATCH 126/422] test(sccm): harden management point evidence boundaries --- .../tests/sccm_server_management_point.rs | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs index 2b7b051ed..df934e8a0 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -590,3 +590,286 @@ fn management_point_catalog_declares_every_reducer_source() { ); } } + +fn analysis_value(bundle: &SccmManagementPointBundle) -> Value { + serde_json::to_value(analyze_management_point(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") + .find("\"lineEnd\":999") + .is_none(), + "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(&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}" + ); + } +} From d27b8f26cc14662b685d003cccaa71deb489a2de Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 08:04:41 -0400 Subject: [PATCH 127/422] fix(sccm): close provider review blockers --- .../cmtraceopen-parser/src/sccm/evidence.rs | 63 ++++++++++++++- .../provider_and_admin_service/README.md | 12 ++- .../admin-service-access-denied/expected.json | 2 +- .../admin-service-auth-failure/expected.json | 2 +- .../expected.json | 2 +- .../admin-service-parse-failed/expected.json | 2 +- .../admin-service-skipped/expected.json | 2 +- .../admin-service-success/expected.json | 2 +- .../blocked-deferred/expected.json | 2 +- .../contradictory-evidence/expected.json | 2 +- .../current/AdminService.log | 8 +- .../iis-supplemental/expected.json | 10 ++- .../iis-supplemental/manifest.json | 2 +- .../incomplete/expected.json | 2 +- .../current/AdminService.log | 6 +- .../provider-local/current/Smsprov.log | 5 +- .../privacy-redaction/expected.json | 13 ++- .../privacy-redaction/manifest.json | 4 +- .../provider-authz-denied/expected.json | 2 +- .../provider-query-failure/expected.json | 2 +- .../provider-retry/expected.json | 2 +- .../provider-source-absent/expected.json | 2 +- .../provider-source-capped/expected.json | 2 +- .../provider-source-unsupported/expected.json | 2 +- .../provider-success/expected.json | 2 +- .../provider-timeout/expected.json | 2 +- .../rotation-boundary/expected.json | 2 +- ...ider_and_admin_service_fixture_contract.rs | 80 +++++++++++++++++-- ...issue-332-provider-admin-service-corpus.md | 9 ++- 29 files changed, 200 insertions(+), 48 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/evidence.rs b/crates/cmtraceopen-parser/src/sccm/evidence.rs index 71b8e46e0..dbb76793d 100644 --- a/crates/cmtraceopen-parser/src/sccm/evidence.rs +++ b/crates/cmtraceopen-parser/src/sccm/evidence.rs @@ -15,7 +15,7 @@ fn sensitive_message_label_re() -> &'static Regex { Regex::new( r#"(?ix) (?: - ["']?\b authorization\b ["']? + ["']?\b authorization(?:[\x20_-]?(?:header|token))?\b ["']? (?:[\x20\t]*[:=][\x20\t]*|[\x20\t]+) (?:[a-z][a-z0-9._-]*[\x20\t]+)? | @@ -139,12 +139,67 @@ fn provider_private_value_end(value: &str, value_start: usize) -> usize { return sensitive_value_end(value, value_start); } - remaining + let line_end = remaining .char_indices() .find_map(|(offset, character)| { - matches!(character, ';' | '\r' | '\n').then_some(value_start + offset) + matches!(character, '\r' | '\n').then_some(value_start + offset) }) - .unwrap_or(value.len()) + .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 { 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 index e9cc019e5..e54c451b8 100644 --- 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 @@ -30,10 +30,17 @@ cannot invent a role, endpoint, or installed component. | `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 | @@ -62,5 +69,6 @@ cannot invent a role, endpoint, or installed component. - 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 a proposal pending reviewed #318 and #335 -interfaces. It must not be treated as an implemented native manifest. +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 index 3ee0e6037..5d6b4bac4 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState": "proposedPendingReviewed318And335", + "contractState": "preparationOnlyReviewedDependenciesAvailable", "workflow": "providerAndAdminService", "scenario": "admin-service-access-denied", "profiles": [ 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 index d56fa2d86..73a2c46cc 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState":"proposedPendingReviewed318And335", + "contractState":"preparationOnlyReviewedDependenciesAvailable", "workflow":"providerAndAdminService", "scenario":"admin-service-auth-failure", "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], 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 index 439146651..9b536f8ad 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState":"proposedPendingReviewed318And335", + "contractState":"preparationOnlyReviewedDependenciesAvailable", "workflow":"providerAndAdminService", "scenario":"admin-service-backend-failure", "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], 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 index 71802e335..a627e7cb8 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState": "proposedPendingReviewed318And335", + "contractState": "preparationOnlyReviewedDependenciesAvailable", "workflow": "providerAndAdminService", "scenario": "admin-service-parse-failed", "profiles": [ 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 index 5667c6d79..a104d6212 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState": "proposedPendingReviewed318And335", + "contractState": "preparationOnlyReviewedDependenciesAvailable", "workflow": "providerAndAdminService", "scenario": "admin-service-skipped", "profiles": [ 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 index 110e11093..040c776d9 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState":"proposedPendingReviewed318And335", + "contractState":"preparationOnlyReviewedDependenciesAvailable", "workflow":"providerAndAdminService", "scenario":"admin-service-success", "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], 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 index 09699a682..067f30e19 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState": "proposedPendingReviewed318And335", + "contractState": "preparationOnlyReviewedDependenciesAvailable", "workflow": "providerAndAdminService", "scenario": "blocked-deferred", "profiles": [ 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 index 2b5b97694..cc392bde4 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState": "proposedPendingReviewed318And335", + "contractState": "preparationOnlyReviewedDependenciesAvailable", "workflow": "providerAndAdminService", "scenario": "contradictory-evidence", "profiles": [ diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/server-admin-service/admin-service-lab/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/server-admin-service/admin-service-lab/current/AdminService.log index 6a58ffe76..f48ff0635 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/server-admin-service/admin-service-lab/current/AdminService.log +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/server-admin-service/admin-service-lab/current/AdminService.log @@ -1,4 +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 index dd96a00c2..d85822b78 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState":"proposedPendingReviewed318And335", + "contractState":"preparationOnlyReviewedDependenciesAvailable", "workflow":"providerAndAdminService", "scenario":"iis-supplemental", "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], @@ -16,9 +16,11 @@ "publicSummary":"Admin Service evidence independently records a terminal success.", "observations":[ {"observationId":"admin-iis-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":1,"endLine":1}]}, - {"observationId":"admin-iis-02-route","phase":"route","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":2,"endLine":2}]}, - {"observationId":"admin-iis-03-respond","phase":"respond","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":3,"endLine":3}]}, - {"observationId":"admin-iis-04-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"admin-iis-current","startLine":4,"endLine":4}]} + {"observationId":"admin-iis-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":2,"endLine":2}]}, + {"observationId":"admin-iis-03-route","phase":"route","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":3,"endLine":3}]}, + {"observationId":"admin-iis-04-backend","phase":"executeBackendOperation","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":4,"endLine":4}]}, + {"observationId":"admin-iis-05-respond","phase":"respond","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":5,"endLine":5}]}, + {"observationId":"admin-iis-06-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"admin-iis-current","startLine":6,"endLine":6}]} ] } ], 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 index 28fbc8041..690d5b80f 100644 --- 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 @@ -6,7 +6,7 @@ "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T22:30:00Z"}, "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"admin-service-lab","layer":"adminService","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, "artifacts":[ - {"artifactId":"admin-iis-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:admin-iis-current","rotation":{"kind":"current","lineageId":"admin-iis","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:30:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":1643,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"}, + {"artifactId":"admin-iis-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:admin-iis-current","rotation":{"kind":"current","lineageId":"admin-iis","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:30:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":2495,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"}, {"artifactId":"iis-supplemental-current","sourceId":"server-admin-service-iis","producerRole":"provider","layer":"supplementalIis","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"supplementalOnly","originalBasename":"u_ex_synthetic.log","sanitizedSourcePath":"SYNTHETIC://scoped-export/LAB/IIS/u_ex_synthetic.log","pathFingerprint":"synthetic:iis-supplemental-current","rotation":{"kind":"current","lineageId":"iis-supplemental","fragmentComplete":true},"captureState":"captured","sourceVersion":"IIS.TEST.0001","collectedUtc":"2026-07-30T22:30:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":157,"relativePath":"evidence/server-admin-service-iis/admin-service-lab/current/u_ex_synthetic.log"} ] } 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 index 153be06c6..67da798e4 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState":"proposedPendingReviewed318And335", + "contractState":"preparationOnlyReviewedDependenciesAvailable", "workflow":"providerAndAdminService", "scenario":"incomplete", "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-admin-service/admin-service-lab/current/AdminService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-admin-service/admin-service-lab/current/AdminService.log index 14e75114e..f49122ede 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-admin-service/admin-service-lab/current/AdminService.log +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-admin-service/admin-service-lab/current/AdminService.log @@ -1,2 +1,6 @@ - + + + + + diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-provider/provider-local/current/Smsprov.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-provider/provider-local/current/Smsprov.log index 6e828f856..204009e3f 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-provider/provider-local/current/Smsprov.log +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-provider/provider-local/current/Smsprov.log @@ -1,2 +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 index db90a48be..7ac7c31d4 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState":"proposedPendingReviewed318And335", + "contractState":"preparationOnlyReviewedDependenciesAvailable", "workflow":"providerAndAdminService", "scenario":"privacy-redaction", "profiles":[ @@ -19,7 +19,11 @@ "publicSummary":"Admin Service privacy fixture completed with redacted public evidence.", "observations":[ {"observationId":"privacy-admin-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-admin-current","startLine":1,"endLine":1}]}, - {"observationId":"privacy-admin-02-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"privacy-admin-current","startLine":2,"endLine":2}]} + {"observationId":"privacy-admin-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-admin-current","startLine":2,"endLine":2}]}, + {"observationId":"privacy-admin-03-route","phase":"route","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-admin-current","startLine":3,"endLine":3}]}, + {"observationId":"privacy-admin-04-backend","phase":"executeBackendOperation","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-admin-current","startLine":4,"endLine":4}]}, + {"observationId":"privacy-admin-05-respond","phase":"respond","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-admin-current","startLine":5,"endLine":5}]}, + {"observationId":"privacy-admin-06-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"privacy-admin-current","startLine":6,"endLine":6}]} ] }, { @@ -30,7 +34,10 @@ "publicSummary":"Provider privacy fixture completed with redacted public evidence.", "observations":[ {"observationId":"privacy-provider-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-provider-current","startLine":1,"endLine":1}]}, - {"observationId":"privacy-provider-02-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"privacy-provider-current","startLine":2,"endLine":2}]} + {"observationId":"privacy-provider-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-provider-current","startLine":2,"endLine":2}]}, + {"observationId":"privacy-provider-03-execute","phase":"executeProviderOperation","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-provider-current","startLine":3,"endLine":3}]}, + {"observationId":"privacy-provider-04-respond","phase":"respond","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-provider-current","startLine":4,"endLine":4}]}, + {"observationId":"privacy-provider-05-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"privacy-provider-current","startLine":5,"endLine":5}]} ] } ], 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 index 3a3de44bd..07f98a102 100644 --- 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 @@ -9,7 +9,7 @@ {"endpointId":"provider-local","layer":"provider","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"} ]}, "artifacts":[ - {"artifactId":"privacy-admin-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:privacy-admin-current","rotation":{"kind":"current","lineageId":"privacy-admin","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:40:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":917,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"}, - {"artifactId":"privacy-provider-current","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.log","pathFingerprint":"synthetic:privacy-provider-current","rotation":{"kind":"current","lineageId":"privacy-provider","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:40:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":945,"relativePath":"evidence/server-provider/provider-local/current/Smsprov.log"} + {"artifactId":"privacy-admin-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:privacy-admin-current","rotation":{"kind":"current","lineageId":"privacy-admin","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:40:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":2603,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"}, + {"artifactId":"privacy-provider-current","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.log","pathFingerprint":"synthetic:privacy-provider-current","rotation":{"kind":"current","lineageId":"privacy-provider","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:40:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":2175,"relativePath":"evidence/server-provider/provider-local/current/Smsprov.log"} ] } 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 index fb0d1bf41..7c0213083 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState":"proposedPendingReviewed318And335", + "contractState":"preparationOnlyReviewedDependenciesAvailable", "workflow":"providerAndAdminService", "scenario":"provider-authz-denied", "profiles":[{"layer":"provider","selectionState":"selectedSynthetic","profileId":"provider-server-5.00.test-v1"}], 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 index bfac6273d..b77adc316 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState":"proposedPendingReviewed318And335", + "contractState":"preparationOnlyReviewedDependenciesAvailable", "workflow":"providerAndAdminService", "scenario":"provider-query-failure", "profiles":[{"layer":"provider","selectionState":"selectedSynthetic","profileId":"provider-server-5.00.test-v1"}], 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 index a1577954d..c1b51744b 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState": "proposedPendingReviewed318And335", + "contractState": "preparationOnlyReviewedDependenciesAvailable", "workflow": "providerAndAdminService", "scenario": "provider-retry", "profiles": [ 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 index f3ac5fd89..a540bc1a8 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState": "proposedPendingReviewed318And335", + "contractState": "preparationOnlyReviewedDependenciesAvailable", "workflow": "providerAndAdminService", "scenario": "provider-source-absent", "profiles": [ 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 index 7cd076a40..24ba97f4a 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState": "proposedPendingReviewed318And335", + "contractState": "preparationOnlyReviewedDependenciesAvailable", "workflow": "providerAndAdminService", "scenario": "provider-source-capped", "profiles": [ 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 index 4c8b67f93..62d6096ab 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState": "proposedPendingReviewed318And335", + "contractState": "preparationOnlyReviewedDependenciesAvailable", "workflow": "providerAndAdminService", "scenario": "provider-source-unsupported", "profiles": [ 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 index 100075b77..31f54e747 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState": "proposedPendingReviewed318And335", + "contractState": "preparationOnlyReviewedDependenciesAvailable", "workflow": "providerAndAdminService", "scenario": "provider-success", "profiles": [{"layer":"provider","selectionState":"selectedSynthetic","profileId":"provider-server-5.00.test-v1"}], 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 index 0975dcecd..8235b6577 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState":"proposedPendingReviewed318And335", + "contractState":"preparationOnlyReviewedDependenciesAvailable", "workflow":"providerAndAdminService", "scenario":"provider-timeout", "profiles":[{"layer":"provider","selectionState":"selectedSynthetic","profileId":"provider-server-5.00.test-v1"}], 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 index dfbde107c..4170c4ff0 100644 --- 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 @@ -1,5 +1,5 @@ { - "contractState":"proposedPendingReviewed318And335", + "contractState":"preparationOnlyReviewedDependenciesAvailable", "workflow":"providerAndAdminService", "scenario":"rotation-boundary", "profiles":[{"layer":"provider","selectionState":"unknownVersion"}], 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 index 2c2abf1e0..ca16f3c56 100644 --- 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 @@ -267,13 +267,13 @@ fn parse_fixture_fields(message: &str) -> Result, Strin "Authorization", ]; let mut fields = BTreeMap::new(); - for segment in segments { + for (segment_index, segment) in segments.enumerate() { if segment.starts_with("[redacted:") && segment.ends_with(']') { continue; } let (name, value) = segment .split_once('=') - .ok_or_else(|| format!("fixture field is not Name=Value: {segment}"))?; + .ok_or_else(|| format!("fixture field at segment {segment_index} is not Name=Value"))?; if !allowed.contains(&name) || value.is_empty() { return Err(format!("unsupported or empty fixture field {name}")); } @@ -521,16 +521,25 @@ fn expected_observation_ids(scenario: &str) -> &'static [&'static str] { ], "iis-supplemental" => &[ "admin-iis-01-receive", - "admin-iis-02-route", - "admin-iis-03-respond", - "admin-iis-04-outcome", + "admin-iis-02-authorize", + "admin-iis-03-route", + "admin-iis-04-backend", + "admin-iis-05-respond", + "admin-iis-06-outcome", ], "incomplete" => &["incomplete-admin-01-receive", "incomplete-admin-02-route"], "privacy-redaction" => &[ "privacy-admin-01-receive", - "privacy-admin-02-outcome", + "privacy-admin-02-authorize", + "privacy-admin-03-route", + "privacy-admin-04-backend", + "privacy-admin-05-respond", + "privacy-admin-06-outcome", "privacy-provider-01-receive", - "privacy-provider-02-outcome", + "privacy-provider-02-authorize", + "privacy-provider-03-execute", + "privacy-provider-04-respond", + "privacy-provider-05-outcome", ], "provider-authz-denied" => &[ "provider-authz-01-receive", @@ -1044,7 +1053,7 @@ fn schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec Vec Vec { + phase_dispositions + == [ + ("receive", "succeeded", false), + ("authenticateOrAuthorize", "succeeded", false), + ("executeProviderOperation", "retryableFailure", false), + ("executeProviderOperation", "succeeded", false), + ("respond", "succeeded", false), + ("recordOutcome", "succeeded", true), + ] + } + "provider" => { + phase_dispositions + == [ + ("receive", "succeeded", false), + ("authenticateOrAuthorize", "succeeded", false), + ("executeProviderOperation", "succeeded", false), + ("respond", "succeeded", false), + ("recordOutcome", "succeeded", true), + ] + } + "adminService" => { + phase_dispositions + == [ + ("receive", "succeeded", false), + ("authenticateOrAuthorize", "succeeded", false), + ("route", "succeeded", false), + ("executeBackendOperation", "succeeded", false), + ("respond", "succeeded", false), + ("recordOutcome", "succeeded", true), + ] + } + _ => false, + }; + if !success_sequence_is_exact { + failures.push( + "successful transaction omits or reorders a required layer phase".to_owned(), + ); + } + } } let mut sorted_transaction_ids = transaction_ids.clone(); sorted_transaction_ids.sort_unstable(); diff --git a/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md b/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md index dd061b524..c15593271 100644 --- a/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md +++ b/docs/sccm/preparation/issue-332-provider-admin-service-corpus.md @@ -84,7 +84,7 @@ noncorrelatable. ## Test-first record -The first focused test failed because the exact eleven-scenario fixture root +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 @@ -104,6 +104,13 @@ 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. From bda57d838a1dc33b8c4783565d129f31bb596500 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 08:06:23 -0400 Subject: [PATCH 128/422] test(sccm): reproduce rotated provenance relabel --- .../origin/lo_/sender.lo_ | 1 + ...rarchy_and_replication_fixture_contract.rs | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/origin/lo_/sender.lo_ 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/sccm_server_hierarchy_and_replication_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_hierarchy_and_replication_fixture_contract.rs index de7c7f4ee..d48951a4e 100644 --- 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 @@ -3365,7 +3365,17 @@ fn hierarchy_coderabbit_0a6ba32_identity_matrices_reject_unknown_scenarios() { 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"); @@ -3377,4 +3387,41 @@ fn hierarchy_coderabbit_d78dc49_complete_lo_owns_send_phase() { }), "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" + ); } From a4ded13c4b989bc2d85e92915d9a8bd68f9b51ae Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 08:07:25 -0400 Subject: [PATCH 129/422] fix(sccm): enforce management point evidence boundaries --- .../sccm/server/windows/management_point.rs | 173 ++++++++++++------ 1 file changed, 118 insertions(+), 55 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs index dcac1071f..f9c37fe6b 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs @@ -5,9 +5,10 @@ use serde::Serialize; use crate::models::log_entry::Severity; use crate::sccm::{ - SccmArtifact, SccmArtifactRequest, SccmConfidence, SccmCoverageState, SccmEvidence, - SccmEvidenceRef, SccmFinding, SccmFindingBuilder, SccmFindingClass, SccmFindingCoverageGap, - SccmKeyConfidence, SccmPhase, SccmRole, SccmTerminalEvidence, SccmTimestamp, + classify_artifact_name, SccmArtifact, SccmArtifactFamily, SccmArtifactRequest, SccmConfidence, + SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmFinding, SccmFindingBuilder, + SccmFindingClass, SccmFindingCoverageGap, SccmKeyConfidence, SccmPhase, SccmRole, + SccmTerminalEvidence, SccmTimestamp, }; pub const SCCM_MANAGEMENT_POINT_ANALYSIS_SCHEMA_VERSION: u32 = 1; @@ -275,14 +276,20 @@ pub fn analyze_management_point(bundle: &SccmManagementPointBundle) -> SccmManag 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) - || !safe_evidence_reference(&evidence.reference) + || !evidence_reference_fits_source(evidence, source) || evidence.role != SccmRole::ManagementPoint - || source.artifact.coverage != SccmCoverageState::Captured - || source.fragment_complete != Some(true) || !topology_is_valid { - if safe_evidence_reference(&evidence.reference) { + if evidence_reference_fits_source(evidence, source) { rejected_references.push(evidence.reference.clone()); } continue; @@ -854,23 +861,23 @@ fn parse_phase_outcome( message: &str, producer: &str, ) -> Option<(SccmManagementPointPhase, FactOutcome, bool)> { - let lowercase = message.to_ascii_lowercase(); + 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 lowercase.contains("receive request succeeded") => { + "MP_GetAuth" if lowercase.starts_with("receive request succeeded") => { Some((SccmManagementPointPhase::ReceiveRequest, succeeded, false)) } - "MP_GetAuth" if lowercase.contains("authenticate succeeded") => { + "MP_GetAuth" if lowercase.starts_with("authenticate succeeded") => { Some((SccmManagementPointPhase::Authenticate, succeeded, false)) } - "MP_GetAuth" if lowercase.contains("authenticate failed terminal") => { + "MP_GetAuth" if lowercase.starts_with("authenticate failed terminal") => { Some((SccmManagementPointPhase::Authenticate, failed, true)) } "MP_CliReg" | "MP_RegistrationManager" - if lowercase.contains("register or identify succeeded") => + if lowercase.starts_with("register or identify succeeded") => { Some(( SccmManagementPointPhase::RegisterOrIdentify, @@ -879,76 +886,80 @@ fn parse_phase_outcome( )) } "MP_CliReg" | "MP_RegistrationManager" - if lowercase.contains("register or identify failed terminal") => + if lowercase.starts_with("register or identify failed terminal") => { Some((SccmManagementPointPhase::RegisterOrIdentify, failed, true)) } - "MP_Location" if lowercase.contains("resolve location succeeded") => Some(( + "MP_Location" if lowercase.starts_with("resolve location succeeded") => Some(( SccmManagementPointPhase::ResolveLocationOrPolicy, succeeded, false, )), - "MP_Location" if lowercase.contains("resolve location failed terminal") => Some(( + "MP_Location" if lowercase.starts_with("resolve location failed terminal") => Some(( SccmManagementPointPhase::ResolveLocationOrPolicy, failed, true, )), - "MP_GetPolicy" if lowercase.contains("resolve policy succeeded") => Some(( + "MP_GetPolicy" if lowercase.starts_with("resolve policy succeeded") => Some(( SccmManagementPointPhase::ResolveLocationOrPolicy, succeeded, false, )), - "MP_GetPolicy" if lowercase.contains("resolve policy failed terminal") => Some(( + "MP_GetPolicy" if lowercase.starts_with("resolve policy failed terminal") => Some(( SccmManagementPointPhase::ResolveLocationOrPolicy, failed, true, )), - "MP_GetPolicy" if lowercase.contains("respond deferred") => { + "MP_GetPolicy" if lowercase.starts_with("respond deferred") => { Some((SccmManagementPointPhase::Respond, deferred, false)) } - "MP_GetPolicy" if lowercase.contains("respond succeeded") => { + "MP_GetPolicy" if lowercase.starts_with("respond succeeded") => { Some((SccmManagementPointPhase::Respond, succeeded, false)) } - "MP_GetPolicy" if lowercase.contains("respond failed terminal") => { + "MP_GetPolicy" if lowercase.starts_with("respond failed terminal") => { Some((SccmManagementPointPhase::Respond, failed, true)) } - "MP_GetPolicy" if lowercase.contains("record outcome succeeded") => { + "MP_GetPolicy" if lowercase.starts_with("record outcome succeeded") => { Some((SccmManagementPointPhase::RecordOutcome, succeeded, false)) } - "MP_GetPolicy" if lowercase.contains("record outcome failed terminal") => { + "MP_GetPolicy" if lowercase.starts_with("record outcome failed terminal") => { Some((SccmManagementPointPhase::RecordOutcome, failed, true)) } _ => None, } } +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 lowercase_name = source.artifact.display_name.to_ascii_lowercase(); - match (source.source_group.as_str(), source.producer.as_str()) { - (MP_AUTH_GROUP, "MP_GetAuth") => supported_basename(&lowercase_name, "mp_getauth"), - (MP_AUTH_GROUP, "MP_CliReg") => supported_basename(&lowercase_name, "mp_clireg"), - (MP_AUTH_GROUP, "MP_RegistrationManager") => { - supported_basename(&lowercase_name, "mp_registrationmanager") - } - (MP_POLICY_GROUP, "MP_GetPolicy") => supported_basename(&lowercase_name, "mp_getpolicy"), - (MP_POLICY_GROUP, "MP_Location") => supported_basename(&lowercase_name, "mp_location"), - _ => false, - } -} - -fn supported_basename(name: &str, stem: &str) -> bool { - name == format!("{stem}.log") - || name == format!("{stem}.lo_") - || name - .strip_prefix(&format!("{stem}.log.")) - .is_some_and(|suffix| { - !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit()) - }) + 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 } fn is_supplemental_source(source: &SccmManagementPointSource) -> bool { @@ -964,20 +975,39 @@ fn is_supplemental_source(source: &SccmManagementPointSource) -> bool { fn token_value(message: &str, label: &str) -> Option { let lowercase = message.to_ascii_lowercase(); let needle = format!("{}=", label.to_ascii_lowercase()); - let start = lowercase.find(&needle)? + needle.len(); - let remainder = &message[start..]; - let (value, _) = if let Some(braced) = remainder.strip_prefix('{') { - let end = braced.find('}')?; - (&braced[..end], end + 2) - } else { + for (label_start, _) in lowercase.match_indices(&needle) { + let exact_label_boundary = label_start == 0 + || message[..label_start] + .chars() + .next_back() + .is_some_and(|character| !character.is_ascii_alphanumeric() && character != '_'); + if !exact_label_boundary { + continue; + } + + let remainder = &message[label_start + needle.len()..]; + 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(|character| { + character.is_whitespace() || matches!(character, ',' | ';' | '&') + }); + if exact_value_boundary && end > 0 { + return Some(braced[..end].to_owned()); + } + continue; + } + let end = remainder .find(|character: char| { character.is_whitespace() || matches!(character, ',' | ';' | '&') }) .unwrap_or(remainder.len()); - (&remainder[..end], end) - }; - (!value.is_empty()).then(|| value.to_owned()) + if end > 0 { + return Some(remainder[..end].to_owned()); + } + } + None } fn normalize_uuid(value: &str) -> Option { @@ -986,7 +1016,10 @@ fn normalize_uuid(value: &str) -> Option { && 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()) } @@ -1015,9 +1048,18 @@ fn valid_safe_handle(value: &str, prefix: &str) -> bool { }; !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'-' | b':')) + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) } fn safe_opaque_id(value: &str) -> bool { @@ -1037,6 +1079,21 @@ fn safe_evidence_reference(reference: &SccmEvidenceRef) -> bool { ) } +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 @@ -1249,6 +1306,9 @@ fn append_unconsumed_explicit_coverage( || 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) && source_is_admitted(source) }) { @@ -1454,7 +1514,10 @@ fn coverage_for_group(bundle: &SccmManagementPointBundle, group: &str) -> SccmCo .filter(|source| source.source_group == group) .map(|source| { if source.artifact.coverage == SccmCoverageState::Captured - && source.fragment_complete != Some(true) + && (source.fragment_complete != Some(true) + || source + .physical_line_end + .is_none_or(|line_end| line_end == 0)) { SccmCoverageState::ParseFailed } else { From dffd14244e08aeb4aad0427d1a0e1bbc2eb43eaa Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 08:32:14 -0400 Subject: [PATCH 130/422] chore(sccm): satisfy strict management point linting --- .../src/sccm/server/windows/management_point.rs | 8 ++++---- .../tests/sccm_server_management_point.rs | 5 ++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs index f9c37fe6b..573733396 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs @@ -1551,11 +1551,11 @@ fn compare_observations( } fn normalize_analysis( - transactions: &mut Vec, - observations: &mut Vec, - findings: &mut Vec, + transactions: &mut [SccmManagementPointTransaction], + observations: &mut [SccmManagementPointSourceLocalObservation], + findings: &mut [SccmManagementPointFinding], coverage_gaps: &mut Vec, - counterpart_facts: &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)); diff --git a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs index df934e8a0..333bf8935 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -719,10 +719,9 @@ fn management_point_evidence_references_must_fit_the_captured_physical_source() let analysis = analysis_value(&bundle); assert_no_high_success(&analysis, "out-of-bounds physical citation"); assert!( - serde_json::to_string(&analysis) + !serde_json::to_string(&analysis) .expect("analysis JSON") - .find("\"lineEnd\":999") - .is_none(), + .contains("\"lineEnd\":999"), "an out-of-bounds citation reached public output" ); } From 9c0e43e96058dca4523a553787ef8d3a5e771f74 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 08:32:48 -0400 Subject: [PATCH 131/422] test(sccm): bind hierarchy basenames to provenance --- ...rarchy_and_replication_fixture_contract.rs | 77 +++++++++++-------- 1 file changed, 46 insertions(+), 31 deletions(-) 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 index d48951a4e..11d7c1584 100644 --- 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 @@ -94,6 +94,15 @@ fn safe_server_handle(value: &str) -> bool { }) } +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), @@ -192,9 +201,7 @@ fn artifact_has_exact_public_provenance(artifact: &Value) -> bool { artifact["producerHostHandle"] .as_str() .is_some_and(safe_server_handle) - && artifact["sanitizedSourcePath"] - .as_str() - .is_some_and(|value| safe_segmented_path(value, "SYNTHETIC://")) + && artifact_path_matches_basename(artifact, "sanitizedSourcePath", "SYNTHETIC://") && artifact["pathFingerprint"].as_str().is_some_and(|value| { value .strip_prefix("synthetic:") @@ -203,6 +210,21 @@ fn artifact_has_exact_public_provenance(artifact: &Value) -> bool { && 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(), @@ -275,7 +297,7 @@ fn record_matches_topology( fn artifact_is_exact_candidate(manifest: &Value, artifact: &Value) -> bool { artifact["captureState"] == "captured" - && artifact["sourceVersion"] == EXACT_SOURCE_VERSION + && artifact_has_exact_public_provenance(artifact) && artifact["collectedUtc"] .as_str() .is_some_and(|value| DateTime::parse_from_rfc3339(value).is_ok()) @@ -285,15 +307,9 @@ fn artifact_is_exact_candidate(manifest: &Value, artifact: &Value) -> bool { && artifact["collectionLimit"]["limitApplied"] .as_bool() .is_some() - && artifact["relativePath"] - .as_str() - .is_some_and(|value| safe_segmented_path(value, "evidence/")) - && artifact["pathFingerprint"].as_str().is_some_and(|value| { - value - .strip_prefix("synthetic:") - .is_some_and(|suffix| !suffix.is_empty()) - }) + && 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(|value| !value.is_empty()) @@ -836,17 +852,9 @@ fn coverage_request_basis( .iter() .map(|artifact| artifact["rotation"]["lineageId"].as_str()) .collect::>>()?; - let canonical_rotation = matching.iter().all(|artifact| { - matches!( - ( - artifact["originalBasename"].as_str(), - artifact["rotation"]["kind"].as_str(), - artifact["rotation"].get("value"), - ), - (Some("sender.log"), Some("current"), None) - | (Some("sender.lo_"), Some("loUnderscore"), None) - ) - }); + 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 @@ -1227,6 +1235,7 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val || !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()) @@ -1267,7 +1276,7 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val match state { Some("captured" | "capped" | "parseFailed") => { let relative_path = artifact["relativePath"].as_str(); - if relative_path.is_none_or(|value| !safe_segmented_path(value, "evidence/")) + if !artifact_path_matches_basename(artifact, "relativePath", "evidence/") || relative_path .map(str::to_ascii_lowercase) .is_none_or(|value| !destinations.insert(value)) @@ -1869,14 +1878,18 @@ fn hierarchy_candidates_are_deterministic_and_collision_resistant() { .expect("healthy artifacts are an array") .clone(); let mut collision = artifacts[1].clone(); - collision["artifactId"] = Value::String("healthy-05-sender-numbered".to_owned()); - collision["pathFingerprint"] = Value::String("synthetic:healthy-sender-numbered".to_owned()); + 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": "numbered", - "value": 1, - "lineageId": "healthy-sender-numbered", + "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()); @@ -2075,7 +2088,9 @@ fn hierarchy_manifest_sources_and_physical_evidence_are_bounded() { "{context}: source escapes the raw CCM hierarchy catalog" )); } - if !artifact_has_exact_public_provenance(artifact) { + if !artifact_has_exact_public_provenance(artifact) + || !artifact_has_canonical_basename_rotation(artifact) + { failures.push(format!("{context}: unsafe or empty provenance")); } if artifact["pathFingerprint"] @@ -2096,7 +2111,7 @@ fn hierarchy_manifest_sources_and_physical_evidence_are_bounded() { let physical = matches!(state, "captured" | "capped" | "parseFailed"); if physical { let relative_path = artifact["relativePath"].as_str().unwrap_or_default(); - if !safe_segmented_path(relative_path, "evidence/") + 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() From 252680cc80c00acfe1ab741cd4b7b02792ef924f Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 08:35:28 -0400 Subject: [PATCH 132/422] test(sccm): freeze management point handoff identity rules --- .../tests/sccm_server_management_point.rs | 98 ++++++++++++++++++- 1 file changed, 95 insertions(+), 3 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs index 333bf8935..9a165fa66 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -294,16 +294,26 @@ fn assert_transaction_contract(scenario: &str, analysis: &Value, expected: &Valu for expected_reference in expected_ranges { assert!( actual_references.iter().any(|reference| { - reference["artifactId"] == expected_reference["artifactId"] + reference_is_within_expected_ranges( + reference, + std::slice::from_ref(expected_reference), + ) }), - "{scenario}: {transaction_id} omitted expected artifact evidence" + "{scenario}: {transaction_id} omitted an expected evidence range" ); } let observations = actual["observations"] .as_array() .expect("transaction observations"); - assert!(!observations.is_empty(), "{scenario}: 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() @@ -562,6 +572,21 @@ fn management_point_counterpart_handoff_requires_an_exact_policy_key() { .is_empty(), "a matching-looking client key cannot become an MP counterpart fact" ); + + let failed = serde_json::to_value(analyze_management_point(&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] @@ -872,3 +897,70 @@ fn management_point_output_never_exports_input_paths_hosts_or_raw_messages() { ); } } + +#[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 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" + ); +} From 6b34d9510162a528b4f7ffd2c00f863c6ae8c1e6 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 08:38:14 -0400 Subject: [PATCH 133/422] fix(sccm): harden management point counterpart handoff --- .../sccm/server/windows/management_point.rs | 50 ++++++++++++++++--- .../tests/sccm_server_management_point.rs | 13 +++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs index 573733396..4a7ff8070 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs @@ -1,3 +1,14 @@ +//! 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.0000` 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}; @@ -185,8 +196,11 @@ pub struct SccmManagementPointCounterpartReadyFact { 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)] @@ -260,11 +274,15 @@ pub fn analyze_management_point(bundle: &SccmManagementPointBundle) -> SccmManag let source_by_artifact = bundle .sources .iter() - .filter(|source| safe_opaque_id(&source.artifact.artifact_id)) + .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_is_valid = valid_site_code(&bundle.topology.site_code) + let topology_site_code = normalize_site_code(&bundle.topology.site_code); + let topology_is_valid = topology_site_code.is_some() && valid_safe_handle(&bundle.topology.management_point_host_handle, "safe:mp:"); let mut facts_by_request: BTreeMap> = BTreeMap::new(); let mut rejected_references = Vec::new(); @@ -297,7 +315,7 @@ pub fn analyze_management_point(bundle: &SccmManagementPointBundle) -> SccmManag match parse_fact(evidence, source) { Some(fact) - if fact.site_code == bundle.topology.site_code + if topology_site_code.as_deref() == Some(fact.site_code.as_str()) && fact.management_point_host_handle == bundle.topology.management_point_host_handle => { @@ -808,8 +826,12 @@ fn build_counterpart_fact( 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()), }) } @@ -834,10 +856,9 @@ fn parse_fact( None => None, }; let client_handle = token_value(message, "ClientHandle")?; - let site_code = token_value(message, "SiteCode")?; + 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_site_code(&site_code) || !valid_safe_handle(&management_point_host_handle, "safe:mp:") { return None; @@ -1038,8 +1059,9 @@ fn has_nonzero_result(message: &str) -> bool { && u32::from_str_radix(hex, 16).is_ok_and(|value| value != 0) } -fn valid_site_code(value: &str) -> bool { - value.len() == 3 && value.bytes().all(|byte| byte.is_ascii_alphanumeric()) +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 { @@ -1070,6 +1092,16 @@ fn safe_opaque_id(value: &str) -> bool { .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 safe_evidence_reference(reference: &SccmEvidenceRef) -> bool { safe_opaque_id(&reference.artifact_id) && safe_opaque_id(&reference.entry_id) @@ -1309,6 +1341,7 @@ fn append_unconsumed_explicit_coverage( && 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) }) { @@ -1537,6 +1570,9 @@ fn coverage_for_group(bundle: &SccmManagementPointBundle, group: &str) -> SccmCo return preferred; } } + if states.contains(&SccmCoverageState::Captured) { + return SccmCoverageState::ParseFailed; + } SccmCoverageState::Absent } diff --git a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs index 9a165fa66..6d9189b2a 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -950,6 +950,19 @@ fn management_point_missing_captured_phase_is_not_reported_as_an_absent_artifact "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"); From eecb54cabd5173fbbe94c558f2a61995586e224c Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 08:43:43 -0400 Subject: [PATCH 134/422] test(sccm): require explicit DP terminal markers --- ..._server_distribution_point_fixture_contract.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index c3b561a1f..76c8dc64c 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -2317,6 +2317,21 @@ fn exact_content_version_dp_topology_and_terminal_evidence_fail_closed() { ); } +#[test] +fn every_observation_requires_an_explicit_typed_terminal_marker() { + let manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let mut expected = read_json("healthy-package", "expected.json").expect("expected loads"); + expected["transactions"][0]["observations"][0] + .as_object_mut() + .expect("nonterminal observation is an object") + .remove("terminal"); + + assert!( + !mutation_was_accepted("healthy-package", &manifest, &expected), + "nonterminal observation without terminal was accepted" + ); +} + #[test] fn coverage_role_and_rotation_states_fail_closed() { let absent_manifest = read_json("absent-dp", "manifest.json").expect("manifest loads"); From 9a63b6bd9494882c1006c17ec6b9e9af0bef91d9 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 08:44:10 -0400 Subject: [PATCH 135/422] fix(sccm): fail closed on missing DP terminal markers --- .../sccm_server_distribution_point_fixture_contract.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index 76c8dc64c..dd6a7cb5e 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -1487,7 +1487,13 @@ fn validate_expected( 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); + let terminal = match required_bool(observation, "terminal", observation_id) { + Ok(value) => value, + Err(error) => { + failures.push(error); + false + } + }; reject_unknown_fields( observation, &[ From afaf6b893e618e4f688bd2b12fe277c1220deb1d Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 08:51:11 -0400 Subject: [PATCH 136/422] test(sccm): bind failed MP handoff to terminal evidence --- .../tests/sccm_server_management_point.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs index 6d9189b2a..e1316e94b 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -589,6 +589,35 @@ fn management_point_counterpart_handoff_requires_an_exact_policy_key() { ); } +#[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 sources = declared_source_catalog() From ba957f1d139591bb6903c274873cd2a08b7882a8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 08:51:36 -0400 Subject: [PATCH 137/422] fix(sccm): cite failed MP terminal handoff evidence --- .../src/sccm/server/windows/management_point.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs index 4a7ff8070..c2ec642ad 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs @@ -808,6 +808,13 @@ fn build_counterpart_fact( let fact = facts .iter() .filter(|fact| fact.policy_id.is_some()) + .filter(|fact| fact.phase <= transaction.phase) + .filter(|fact| { + transaction.state != SccmManagementPointState::Failed + || (fact.phase == transaction.phase + && fact.outcome == FactOutcome::Failed + && fact.terminal) + }) .filter(|fact| { matches!( fact.timestamp.ordering_state, From eb8a71b87da56dca533dad102a853ac51d5fcbc1 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 09:00:05 -0400 Subject: [PATCH 138/422] test(sccm): freeze hierarchy review gaps --- ...rarchy_and_replication_fixture_contract.rs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) 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 index 11d7c1584..b41d872aa 100644 --- 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 @@ -3440,3 +3440,119 @@ fn hierarchy_coderabbit_d78dc49_complete_lo_owns_send_phase() { "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:?}" + ); +} + +#[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_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")); +} From edd6461c6635a461a6b87884c44bae937e0531fa Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 09:01:38 -0400 Subject: [PATCH 139/422] fix(sccm): close hierarchy evidence boundaries --- .../hierarchy_and_replication/README.md | 2 +- ...rarchy_and_replication_fixture_contract.rs | 79 ++++++++++++++++--- 2 files changed, 69 insertions(+), 12 deletions(-) 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 index 63f6e42f6..f3535d4a1 100644 --- 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 @@ -5,7 +5,7 @@ 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 raw CCM files from the existing hierarchy catalog are present: +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 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 index b41d872aa..a60f5194a 100644 --- 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 @@ -33,6 +33,7 @@ const STATE_CHAIN: &[&str] = &[ 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; fn corpus_root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -71,6 +72,18 @@ fn required_string<'a>(value: &'a Value, field: &str, context: &str) -> Result<& .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| { !suffix.is_empty() @@ -202,11 +215,9 @@ fn artifact_has_exact_public_provenance(artifact: &Value) -> bool { .as_str() .is_some_and(safe_server_handle) && artifact_path_matches_basename(artifact, "sanitizedSourcePath", "SYNTHETIC://") - && artifact["pathFingerprint"].as_str().is_some_and(|value| { - value - .strip_prefix("synthetic:") - .is_some_and(|suffix| !suffix.is_empty()) - }) + && artifact["pathFingerprint"] + .as_str() + .is_some_and(|value| safe_prefixed_opaque_id(value, "synthetic:")) && artifact["sourceVersion"].as_str() == Some(EXACT_SOURCE_VERSION) } @@ -296,7 +307,8 @@ fn record_matches_topology( } fn artifact_is_exact_candidate(manifest: &Value, artifact: &Value) -> bool { - artifact["captureState"] == "captured" + artifact["artifactId"].as_str().is_some_and(safe_opaque_id) + && artifact["captureState"] == "captured" && artifact_has_exact_public_provenance(artifact) && artifact["collectedUtc"] .as_str() @@ -312,7 +324,7 @@ fn artifact_is_exact_candidate(manifest: &Value, artifact: &Value) -> bool { && artifact_has_canonical_basename_rotation(artifact) && artifact["rotation"]["lineageId"] .as_str() - .is_some_and(|value| !value.is_empty()) + .is_some_and(safe_opaque_id) && artifact["rotation"]["fragmentComplete"] == true && artifact_has_exact_source_tuple(artifact) && artifact_matches_topology(manifest, artifact) @@ -1221,9 +1233,9 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val } let Some(artifact_id) = artifact["artifactId"] .as_str() - .filter(|artifact_id| !artifact_id.is_empty()) + .filter(|value| safe_opaque_id(value)) else { - failures.push("artifactId is not a non-empty string".to_owned()); + failures.push("artifactId is not a bounded safe opaque string".to_owned()); continue; }; artifact_ids.push(artifact_id); @@ -1269,7 +1281,7 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val if rotation(&artifact["rotation"]).is_none() || artifact["rotation"]["lineageId"] .as_str() - .is_none_or(str::is_empty) + .is_none_or(|value| !safe_opaque_id(value)) { failures.push(format!("{artifact_id}: invalid rotation provenance")); } @@ -1421,7 +1433,20 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val { failures.push("state chain is not exact typed #331 grammar".to_owned()); } - if expected["analysisContract"]["independentReducer"] != true + 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" @@ -1572,6 +1597,38 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val .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 derived_confidence = if transaction["topologyCompatibility"] == "exact" + && transaction["timestampOrdering"] == "usable" + && transaction["terminalEvidence"] == true + && gap_ids.is_empty() + { + "high" + } else if transaction["topologyCompatibility"] == "exact" + && transaction["timestampOrdering"] == "usable" + && transaction["terminalEvidence"] == false + && gap_ids.is_empty() + && has_retrying_fact + { + "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" From 3bcfdd03443b31b51ae4f7f915f7cd6bf984e2b4 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 09:04:01 -0400 Subject: [PATCH 140/422] test(sccm): reproduce DP semantic review gaps --- ...ver_distribution_point_fixture_contract.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index dd6a7cb5e..3ff222b70 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -3710,6 +3710,78 @@ fn later_same_key_success_prevents_stale_high_terminal_failure() { ); } +#[test] +fn uncited_later_same_key_terminal_failure_invalidates_high_success() { + let temporary = temporary_scenario("healthy-package"); + let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let relative_path = manifest["artifacts"][2]["relativePath"] + .as_str() + .expect("provider artifact has a path") + .to_owned(); + let fixture_path = temporary.root.join(&relative_path); + let mut contents = + std::fs::read_to_string(&fixture_path).expect("provider fixture is readable"); + contents.push_str( + "\n", + ); + std::fs::write(&fixture_path, contents).expect("later terminal failure is written"); + refresh_artifact_bytes(&mut manifest, 2, &temporary.root); + + assert!( + !mutation_at_root_was_accepted("healthy-package", &temporary.root, &manifest, &expected,), + "an uncited later same-key terminal failure retained stale high success" + ); +} + +#[test] +fn admitted_distribution_point_producers_require_the_observed_role() { + let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let mut expected = read_json("healthy-package", "expected.json").expect("expected loads"); + manifest["topology"]["rolesObserved"] = json!(["siteServer"]); + expected["roleAssessment"]["distributionPointObserved"] = json!(false); + + assert!( + !mutation_was_accepted("healthy-package", &manifest, &expected), + "DP-produced evidence retained high success while the DP role was not observed" + ); +} + +#[test] +fn distribution_point_observed_is_a_required_boolean() { + let manifest = + read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); + let expected = + read_json("client-only-looking-request", "expected.json").expect("expected loads"); + let mut accepted = Vec::new(); + + let mut missing = expected.clone(); + missing["roleAssessment"] + .as_object_mut() + .expect("role assessment is an object") + .remove("distributionPointObserved"); + if mutation_was_accepted("client-only-looking-request", &manifest, &missing) { + accepted.push("missing"); + } + + for (shape, value) in [ + ("null", Value::Null), + ("string", json!("false")), + ("number", json!(0)), + ] { + let mut malformed = expected.clone(); + malformed["roleAssessment"]["distributionPointObserved"] = value; + if mutation_was_accepted("client-only-looking-request", &manifest, &malformed) { + accepted.push(shape); + } + } + + assert!( + accepted.is_empty(), + "distributionPointObserved accepted non-Boolean shapes: {accepted:?}" + ); +} + #[test] fn incomplete_transaction_gaps_are_bound_to_the_exact_distribution_point() { let mut manifest = read_json("incomplete", "manifest.json").expect("manifest loads"); From ee168ccfb05c926b95b8c0cef6d97bc53262da13 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 09:05:00 -0400 Subject: [PATCH 141/422] fix(sccm): close DP evidence and role gaps --- ...ver_distribution_point_fixture_contract.rs | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index 3ff222b70..94e7c11c2 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -712,6 +712,11 @@ fn validate_manifest( "{artifact_id} has an uncatalogued source/producer/basename combination" )); } + if role != "client" && !roles.contains(&role) { + failures.push(format!( + "{artifact_id} producer role {role} is absent from rolesObserved" + )); + } let workflow_subject_handle = artifact["workflowSubjectHandle"].as_str(); let workflow_subject_basis = artifact["workflowSubjectBasis"].as_str(); if artifact["workflowSubjectRole"] != "distributionPoint" @@ -1285,6 +1290,17 @@ fn validate_expected( { failures.push("expected output infers role state from source coverage".to_owned()); } + let distribution_point_observed = match required_bool( + &expected["roleAssessment"], + "distributionPointObserved", + "roleAssessment", + ) { + Ok(value) => Some(value), + Err(error) => { + failures.push(error); + None + } + }; let expected_coverage = parsed .artifacts @@ -1994,6 +2010,17 @@ fn validate_expected( } } + let unconsumed_normalized_evidence = parsed + .evidence + .keys() + .filter(|key| !consumed_evidence.contains(*key)) + .collect::>(); + if !unconsumed_normalized_evidence.is_empty() { + failures.push(format!( + "normalized logical records lack an explicit transaction or source-local classification: {unconsumed_normalized_evidence:?}" + )); + } + let requests = match required_array(expected, "artifactRequests", "expected") { Ok(value) => value, Err(error) => { @@ -2089,8 +2116,7 @@ fn validate_expected( } if scenario == "absent-dp" - && (expected["roleAssessment"]["distributionPointObserved"] != true - || !transactions.is_empty()) + && (distribution_point_observed != Some(true) || !transactions.is_empty()) { failures.push("absent-dp must retain the observed role without a diagnosis".to_owned()); } @@ -2133,13 +2159,10 @@ fn validate_expected( } } - if manifest["topology"]["rolesObserved"] + let topology_distribution_point_observed = manifest["topology"]["rolesObserved"] .as_array() - .is_some_and(|roles| roles.iter().any(|role| role == "distributionPoint")) - != expected["roleAssessment"]["distributionPointObserved"] - .as_bool() - .unwrap_or(false) - { + .is_some_and(|roles| roles.iter().any(|role| role == "distributionPoint")); + if distribution_point_observed != Some(topology_distribution_point_observed) { failures.push("role assessment is not an exact topology projection".to_owned()); } From 814f78412385c8aeadc16635a4cb41d46f366466 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 09:21:55 -0400 Subject: [PATCH 142/422] test(sccm): reproduce overlapping identity leaks --- .../cmtraceopen-parser/src/sccm/evidence.rs | 50 +++++++++++++++++++ ...ider_and_admin_service_fixture_contract.rs | 24 ++++++--- .../tests/sccm_spine_contract.rs | 5 ++ 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/evidence.rs b/crates/cmtraceopen-parser/src/sccm/evidence.rs index dbb76793d..d424d9c54 100644 --- a/crates/cmtraceopen-parser/src/sccm/evidence.rs +++ b/crates/cmtraceopen-parser/src/sccm/evidence.rs @@ -413,4 +413,54 @@ mod tests { .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!("Profile {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/tests/sccm_server_provider_and_admin_service_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service_fixture_contract.rs index ca16f3c56..c6004fff6 100644 --- 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 @@ -2937,15 +2937,23 @@ fn provider_retry_recovery_is_bound_to_the_failed_phase() { #[test] fn rejected_fixture_segment_diagnostic_omits_raw_value() { let sensitive = "private-credential-value"; - let message = format!( - "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=receive; AuthorizationHeader {sensitive}" - ); - let error = parse_fixture_fields(&message).expect_err("malformed fixture segment is rejected"); + let messages = [ + format!( + "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=receive; AuthorizationHeader {sensitive}" + ), + format!( + "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=receive; Bearer {sensitive}=1" + ), + ]; - assert!( - !error.contains(sensitive), - "rejected fixture diagnostic echoed a raw private value: {error}" - ); + for message in messages { + let error = + parse_fixture_fields(&message).expect_err("malformed fixture segment is rejected"); + assert!( + !error.contains(sensitive), + "rejected fixture diagnostic echoed a raw private value: {error}" + ); + } } #[test] diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 711c40b56..78e722edd 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -4652,6 +4652,11 @@ fn evidence_public_message_projection_redacts_provider_handles_in_all_positions( "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", From 59af7b5f3315698f9d6fb8da0b2ac756799ec9d4 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 09:23:45 -0400 Subject: [PATCH 143/422] fix(sccm): merge overlapping redaction ranges --- crates/cmtraceopen-parser/src/sccm/evidence.rs | 13 ++++++++++--- ...r_provider_and_admin_service_fixture_contract.rs | 4 +++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/evidence.rs b/crates/cmtraceopen-parser/src/sccm/evidence.rs index d424d9c54..1a37dac9b 100644 --- a/crates/cmtraceopen-parser/src/sccm/evidence.rs +++ b/crates/cmtraceopen-parser/src/sccm/evidence.rs @@ -259,11 +259,18 @@ fn redact_windows_identities(value: &str) -> String { 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 start < copied_through { - continue; + 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; @@ -417,7 +424,7 @@ mod tests { #[test] fn export_merges_overlapping_identity_ranges_without_mutating_raw_snapshot() { let raw_identity = r"users\ADMIN\secret"; - let raw_message = format!("Profile {raw_identity}; status=71"); + let raw_message = format!("{raw_identity}; status=71"); let text = format!( r#""# ); 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 index c6004fff6..865be2cd5 100644 --- 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 @@ -275,7 +275,9 @@ fn parse_fixture_fields(message: &str) -> Result, Strin .split_once('=') .ok_or_else(|| format!("fixture field at segment {segment_index} is not Name=Value"))?; if !allowed.contains(&name) || value.is_empty() { - return Err(format!("unsupported or empty fixture field {name}")); + return Err(format!( + "unsupported or empty fixture field at segment {segment_index}" + )); } if matches!(name, "CallerHandle" | "QueryHandle" | "Authorization") { if value != "[redacted:sccm-public-message-v1]" { From 04adaf83757975af1904336558306fa4e2aa44f5 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 09:26:20 -0400 Subject: [PATCH 144/422] test(sccm): reproduce hierarchy grammar bounds --- ...rarchy_and_replication_fixture_contract.rs | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) 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 index a60f5194a..220c52c5d 100644 --- 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 @@ -3589,6 +3589,176 @@ fn hierarchy_candidate_identity_and_provenance_are_safe_bounded_opaque_ids() { ); } +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"); From 142ff8d3aea0121b1ca080c70dc1448e13552161 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 09:27:32 -0400 Subject: [PATCH 145/422] fix(sccm): bound hierarchy candidate grammar --- ...rarchy_and_replication_fixture_contract.rs | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) 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 index 220c52c5d..ee3a830ee 100644 --- 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 @@ -2,8 +2,9 @@ use std::collections::{BTreeMap, BTreeSet}; use chrono::DateTime; use cmtraceopen_parser::sccm::{ - classify_artifact_name, normalize_ccm_artifact, SccmArtifact, SccmArtifactFamily, - SccmCoverageState, SccmEvidence, SccmRole, SccmRotation, SccmTimeOrderingState, + classify_artifact_name, normalize_ccm_artifact, normalize_key, SccmArtifact, + SccmArtifactFamily, SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, SccmKeyConfidence, + SccmRole, SccmRotation, SccmTimeOrderingState, }; use serde_json::Value; @@ -34,6 +35,8 @@ const STATE_CHAIN: &[&str] = &[ 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")) @@ -86,10 +89,12 @@ fn safe_prefixed_opaque_id(value: &str, prefix: &str) -> bool { fn safe_segmented_path(value: &str, prefix: &str) -> bool { value.strip_prefix(prefix).is_some_and(|suffix| { - !suffix.is_empty() + 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'-') @@ -99,12 +104,9 @@ fn safe_segmented_path(value: &str, prefix: &str) -> bool { } fn safe_server_handle(value: &str) -> bool { - value.strip_prefix("safe:server:").is_some_and(|payload| { - !payload.is_empty() - && payload - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) - }) + value + .strip_prefix("safe:server:") + .is_some_and(safe_opaque_id) } fn artifact_path_matches_basename(artifact: &Value, field: &str, prefix: &str) -> bool { @@ -177,10 +179,33 @@ fn parse_fixture_fields(message: &str) -> Result, Strin { 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) } From 44c2fad0b861afec06cf0ca753eece242fad0b36 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 09:30:44 -0400 Subject: [PATCH 146/422] test(sccm): reproduce stale DP outcome accepts --- ...ver_distribution_point_fixture_contract.rs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index 94e7c11c2..67f94f72e 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -2279,6 +2279,17 @@ fn replace_fixture_text( .expect("temporary fixture mutation is written"); } +fn append_fixture_record(scenario_root: &std::path::Path, relative_path: &str, record: &str) { + let path = scenario_root.join(relative_path); + let mut contents = std::fs::read_to_string(&path).expect("temporary fixture is readable"); + if !contents.ends_with('\n') { + contents.push('\n'); + } + contents.push_str(record); + contents.push('\n'); + std::fs::write(&path, contents).expect("temporary fixture mutation is written"); +} + fn refresh_artifact_bytes( manifest: &mut Value, artifact_index: usize, @@ -3304,6 +3315,73 @@ fn terminal_success_is_bound_to_the_cited_serve_or_report_record() { ); } +#[test] +fn later_same_key_retry_invalidates_stale_high_success() { + let temporary = temporary_scenario("healthy-package"); + let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let mut expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let relative_path = manifest["artifacts"][2]["relativePath"] + .as_str() + .expect("provider artifact has a path") + .to_owned(); + + append_fixture_record( + &temporary.root, + &relative_path, + r#""#, + ); + refresh_artifact_bytes(&mut manifest, 2, &temporary.root); + expected["transactions"][0]["observations"] + .as_array_mut() + .expect("observations are an array") + .push(json!({ + "observationId": "07-report-retry", + "phase": "serveOrReport", + "disposition": "retrying", + "terminal": false, + "evidence": [{"artifactId": "dp-healthy-03-provider", "startLine": 4, "endLine": 4}] + })); + + assert!( + !mutation_at_root_was_accepted("healthy-package", &temporary.root, &manifest, &expected,), + "a later same-key retry retained stale high-confidence success" + ); +} + +#[test] +fn later_same_key_success_invalidates_stale_deferred_outcome() { + let temporary = temporary_scenario("transfer-retry"); + let mut manifest = read_json("transfer-retry", "manifest.json").expect("manifest loads"); + let mut expected = read_json("transfer-retry", "expected.json").expect("expected loads"); + let relative_path = manifest["artifacts"][1]["relativePath"] + .as_str() + .expect("transfer artifact has a path") + .to_owned(); + + append_fixture_record( + &temporary.root, + &relative_path, + r#""#, + ); + refresh_artifact_bytes(&mut manifest, 1, &temporary.root); + expected["transactions"][0]["lastSuccessfulPhase"] = json!("transfer"); + expected["transactions"][0]["observations"] + .as_array_mut() + .expect("observations are an array") + .push(json!({ + "observationId": "04-transfer-succeeded", + "phase": "transfer", + "disposition": "succeeded", + "terminal": false, + "evidence": [{"artifactId": "dp-transfer-retry-02-pkgxfer", "startLine": 2, "endLine": 2}] + })); + + assert!( + !mutation_at_root_was_accepted("transfer-retry", &temporary.root, &manifest, &expected,), + "a later same-key success retained stale deferred classification" + ); +} + #[test] fn correlation_eligible_incomplete_output_requires_evidence_gaps_and_requests() { let manifest = read_json("incomplete", "manifest.json").expect("manifest loads"); From 4f8bd3b6e89ab208932446756ed6a29596f8da4c Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 09:34:53 -0400 Subject: [PATCH 147/422] fix(sccm): bind DP state to latest evidence --- .../tests/sccm_server_distribution_point_fixture_contract.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index 67f94f72e..8262f383d 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -1473,6 +1473,7 @@ fn validate_expected( } let mut latest_success: Option = None; + let mut latest_outcome: Option<(&str, bool)> = None; let mut terminal_success = false; let mut terminal_success_phase = None; let mut terminal_failure = false; @@ -1635,6 +1636,7 @@ fn validate_expected( "{observation_id} uses an incoherent disposition/terminal pair" )), } + latest_outcome = Some((disposition, terminal)); } let computed_last_success = latest_success.map(|index| STATE_CHAIN[index]); @@ -1655,6 +1657,7 @@ fn validate_expected( match (state, classification) { ("succeeded", "success") if terminal_success + && latest_outcome == Some(("succeeded", true)) && terminal_success_phase == STATE_CHAIN .iter() @@ -1675,6 +1678,7 @@ fn validate_expected( && confidence_ceiling == "high" => {} ("deferred", "blockedOrDeferred") if terminal_deferred + && matches!(latest_outcome, Some(("deferred" | "retrying", false))) && !terminal_failure && !terminal_success && !cites_capped_evidence From f12840f79148ff26714d3170ce37da827e93caa0 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 09:46:59 -0400 Subject: [PATCH 148/422] test(sccm): reproduce phase-specific failure drift --- ...ider_and_admin_service_fixture_contract.rs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) 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 index 865be2cd5..091a5cc58 100644 --- 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 @@ -2902,6 +2902,93 @@ fn high_success_requires_every_layer_phase_in_order() { ); } +#[test] +fn phase_specific_failures_require_the_named_failed_phase() { + let cases = [ + ( + "provider-authz-denied", + 1_usize, + "authenticateOrAuthorize", + "receive", + "provider authorization", + ), + ( + "admin-service-auth-failure", + 1_usize, + "authenticateOrAuthorize", + "receive", + "Admin Service authentication", + ), + ( + "provider-query-failure", + 2_usize, + "executeProviderOperation", + "authenticateOrAuthorize", + "Provider operation", + ), + ( + "admin-service-backend-failure", + 3_usize, + "executeBackendOperation", + "route", + "Admin Service backend operation", + ), + ]; + let mut accepted = Vec::new(); + + for (scenario, observation_index, failed_phase, replacement_phase, label) in cases { + for (mutation, replacement_phase) in [ + ("without its named phase", Some(replacement_phase)), + ("with a successful named phase", None), + ] { + let manifest = read_json(scenario, "manifest.json").unwrap(); + let mut expected = read_json(scenario, "expected.json").unwrap(); + let mut records = normalized_records(scenario, &manifest); + assert!( + schema_failures_with_records(scenario, &manifest, &expected, &records).is_empty(), + "{scenario}: baseline contract is invalid" + ); + + let observation = &mut expected["transactions"][0]["observations"][observation_index]; + if let Some(replacement_phase) = replacement_phase { + observation["phase"] = Value::String(replacement_phase.to_owned()); + } + observation["disposition"] = Value::String("succeeded".to_owned()); + let artifact_id = observation["evidence"][0]["artifactId"] + .as_str() + .unwrap() + .to_owned(); + let start_line = + u32::try_from(observation["evidence"][0]["startLine"].as_u64().unwrap()).unwrap(); + let end_line = + u32::try_from(observation["evidence"][0]["endLine"].as_u64().unwrap()).unwrap(); + let record = records + .get_mut(&(artifact_id, start_line, end_line)) + .expect("paired logical record exists"); + if let Some(replacement_phase) = replacement_phase { + record.message = record.message.replacen( + &format!("Phase={failed_phase}"), + &format!("Phase={replacement_phase}"), + 1, + ); + } + record.message = + record + .message + .replacen("Disposition=failed", "Disposition=succeeded", 1); + + if schema_failures_with_records(scenario, &manifest, &expected, &records).is_empty() { + accepted.push(format!("{label} {mutation}")); + } + } + } + + assert!( + accepted.is_empty(), + "phase-specific failure summaries survived without their named failed phase: {accepted:#?}" + ); +} + #[test] fn provider_retry_recovery_is_bound_to_the_failed_phase() { let scenario = "provider-retry"; From ea0546355efac441763fa21405472f36b28dfe86 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 09:47:55 -0400 Subject: [PATCH 149/422] fix(sccm): bind failure claims to exact phases --- ...ider_and_admin_service_fixture_contract.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) 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 index 091a5cc58..8def5837c 100644 --- 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 @@ -1534,6 +1534,43 @@ fn schema_failures(scenario: &str, manifest: &Value, expected: &Value) -> Vec { + phase_dispositions + == [ + ("receive", "succeeded", false), + ("authenticateOrAuthorize", "failed", false), + ("recordOutcome", "failed", true), + ] + } + "provider-query-failure" => { + phase_dispositions + == [ + ("receive", "succeeded", false), + ("authenticateOrAuthorize", "succeeded", false), + ("executeProviderOperation", "failed", false), + ("recordOutcome", "failed", true), + ] + } + "admin-service-backend-failure" => { + phase_dispositions + == [ + ("receive", "succeeded", false), + ("authenticateOrAuthorize", "succeeded", false), + ("route", "succeeded", false), + ("executeBackendOperation", "failed", false), + ("recordOutcome", "failed", true), + ] + } + _ => false, + }; + if !failure_sequence_is_exact { + failures.push( + "phase-specific failure omits or reorders its exact failed phase".to_owned(), + ); + } + } } let mut sorted_transaction_ids = transaction_ids.clone(); sorted_transaction_ids.sort_unstable(); From cc2c0e3510b8ebbc6446ccd10cce5ae01c5b6f79 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 09:44:50 -0400 Subject: [PATCH 150/422] test(sccm): expose management point ambiguity gaps --- .../tests/sccm_server_management_point.rs | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs index e1316e94b..d8c49be04 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -1006,3 +1006,159 @@ fn management_point_missing_captured_phase_is_not_reported_as_an_absent_artifact "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" + ); +} From 2e54a20faa244e5c429792127cc91d530d5155b7 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 10:01:24 -0400 Subject: [PATCH 151/422] test(sccm): reproduce equal-time DP ordering gaps --- ...ver_distribution_point_fixture_contract.rs | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index 8262f383d..dbb616e31 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -3386,6 +3386,225 @@ fn later_same_key_success_invalidates_stale_deferred_outcome() { ); } +#[test] +fn equal_utc_same_artifact_retry_cannot_be_reordered_before_terminal_success() { + let temporary = temporary_scenario("healthy-package"); + let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let mut expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let relative_path = manifest["artifacts"][2]["relativePath"] + .as_str() + .expect("provider artifact has a path") + .to_owned(); + + append_fixture_record( + &temporary.root, + &relative_path, + r#""#, + ); + refresh_artifact_bytes(&mut manifest, 2, &temporary.root); + expected["transactions"][0]["observations"] + .as_array_mut() + .expect("observations are an array") + .insert( + 5, + json!({ + "observationId": "05-report-retry", + "phase": "serveOrReport", + "disposition": "retrying", + "terminal": false, + "evidence": [{ + "artifactId": "dp-healthy-03-provider", + "startLine": 4, + "endLine": 4 + }] + }), + ); + + assert!( + !mutation_at_root_was_accepted("healthy-package", &temporary.root, &manifest, &expected), + "an observation ID reordered a later equal-UTC physical retry before stale high success" + ); +} + +#[test] +fn equal_utc_same_artifact_recovery_cannot_be_reordered_before_terminal_failure() { + let temporary = temporary_scenario("validation-failure"); + let mut manifest = read_json("validation-failure", "manifest.json").expect("manifest loads"); + let mut expected = read_json("validation-failure", "expected.json").expect("expected loads"); + let relative_path = manifest["artifacts"][2]["relativePath"] + .as_str() + .expect("provider artifact has a path") + .to_owned(); + + append_fixture_record( + &temporary.root, + &relative_path, + r#""#, + ); + refresh_artifact_bytes(&mut manifest, 2, &temporary.root); + expected["transactions"][0]["lastSuccessfulPhase"] = json!("validate"); + expected["transactions"][0]["observations"] + .as_array_mut() + .expect("observations are an array") + .insert( + 3, + json!({ + "observationId": "03-validate-recovered", + "phase": "validate", + "disposition": "succeeded", + "terminal": false, + "evidence": [{ + "artifactId": "dp-validation-failure-03-provider", + "startLine": 2, + "endLine": 2 + }] + }), + ); + + assert!( + !mutation_at_root_was_accepted("validation-failure", &temporary.root, &manifest, &expected,), + "an observation ID reordered a later equal-UTC physical recovery before stale high failure" + ); +} + +#[test] +fn equal_utc_cross_artifact_outcomes_fail_closed_as_ambiguous() { + let temporary = temporary_scenario("serve-observed"); + let mut manifest = read_json("serve-observed", "manifest.json").expect("manifest loads"); + let mut expected = read_json("serve-observed", "expected.json").expect("expected loads"); + let relative_path = manifest["artifacts"][2]["relativePath"] + .as_str() + .expect("provider artifact has a path") + .to_owned(); + + append_fixture_record( + &temporary.root, + &relative_path, + r#""#, + ); + refresh_artifact_bytes(&mut manifest, 2, &temporary.root); + expected["transactions"][0]["observations"] + .as_array_mut() + .expect("observations are an array") + .insert( + 5, + json!({ + "observationId": "05-serve-retry", + "phase": "serveOrReport", + "disposition": "retrying", + "terminal": false, + "evidence": [{ + "artifactId": "dp-serve-03-provider", + "startLine": 3, + "endLine": 3 + }] + }), + ); + + assert!( + !mutation_at_root_was_accepted("serve-observed", &temporary.root, &manifest, &expected), + "different artifacts at equal UTC used observation IDs to retain stale high success" + ); +} + +#[test] +fn equal_utc_cross_rotation_outcomes_fail_closed_as_ambiguous() { + let temporary = temporary_scenario("healthy-package"); + let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let mut expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let current_relative_path = manifest["artifacts"][2]["relativePath"] + .as_str() + .expect("provider artifact has a path") + .to_owned(); + let rollback_relative_path = "evidence/server-dp-distribution/dp/lo_/SMSDPProv.log".to_owned(); + let rollback_fixture_path = temporary.root.join(&rollback_relative_path); + std::fs::create_dir_all( + rollback_fixture_path + .parent() + .expect("rollback fixture has a parent"), + ) + .expect("rollback fixture parent is created"); + std::fs::copy( + temporary.root.join(¤t_relative_path), + &rollback_fixture_path, + ) + .expect("current provider evidence is copied to the rollback artifact"); + manifest["artifacts"][2]["sanitizedSourcePath"] = + json!("SYNTHETIC://dp-root/Logs/SMSDPProv.lo_"); + manifest["artifacts"][2]["rotation"]["kind"] = json!("lo_"); + manifest["artifacts"][2]["relativePath"] = json!(rollback_relative_path); + refresh_artifact_bytes(&mut manifest, 2, &temporary.root); + + let current_record = r#""#; + std::fs::write( + temporary.root.join(¤t_relative_path), + format!("{current_record}\n"), + ) + .expect("current rotation retry fixture is written"); + let current_bytes = std::fs::metadata(temporary.root.join(¤t_relative_path)) + .expect("current rotation retry fixture is readable") + .len(); + manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .push(json!({ + "artifactId": "dp-healthy-04-provider-current", + "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": "healthy-provider", + "fragmentComplete": true + }, + "captureState": "captured", + "sourceVersion": "5.00.TEST.0001", + "collectedUtc": "2026-07-30T12:10:00Z", + "encoding": "utf-8", + "collectionLimit": { + "byteLimit": 4096, + "limitApplied": false + }, + "bytesCopied": current_bytes, + "relativePath": current_relative_path + })); + expected["coverage"] + .as_array_mut() + .expect("coverage is an array") + .push(json!({ + "artifactId": "dp-healthy-04-provider-current", + "state": "captured" + })); + expected["transactions"][0]["observations"] + .as_array_mut() + .expect("observations are an array") + .insert( + 5, + json!({ + "observationId": "05-report-retry", + "phase": "serveOrReport", + "disposition": "retrying", + "terminal": false, + "evidence": [{ + "artifactId": "dp-healthy-04-provider-current", + "startLine": 1, + "endLine": 1 + }] + }), + ); + + assert!( + !mutation_at_root_was_accepted("healthy-package", &temporary.root, &manifest, &expected), + "equal-UTC current/rollback outcomes used observation IDs to retain stale high success" + ); +} + #[test] fn correlation_eligible_incomplete_output_requires_evidence_gaps_and_requests() { let manifest = read_json("incomplete", "manifest.json").expect("manifest loads"); From 0d1ccefdfeb2d2021e875ffe80a8cae4af99ccec Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 10:02:28 -0400 Subject: [PATCH 152/422] fix(sccm): fail closed on management point ambiguity --- .../sccm/server/windows/management_point.rs | 218 +++++++++--------- .../tests/sccm_server_management_point.rs | 52 +++-- 2 files changed, 142 insertions(+), 128 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs index c2ec642ad..1380ff04b 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs @@ -304,6 +304,7 @@ pub fn analyze_management_point(bundle: &SccmManagementPointBundle) -> SccmManag } if !source_is_admitted(source) || !evidence_reference_fits_source(evidence, source) + || !evidence_identity_is_unique(bundle, evidence) || evidence.role != SccmRole::ManagementPoint || !topology_is_valid { @@ -628,74 +629,39 @@ fn resolve_phase<'a>(facts: &[&'a ManagementPointFact]) -> PhaseDecision<'a> { }; } - let latest_failure = latest_fact( - facts - .iter() - .copied() - .filter(|fact| fact.outcome == FactOutcome::Failed && fact.terminal), - ); - let latest_success = latest_fact( - facts - .iter() - .copied() - .filter(|fact| fact.outcome == FactOutcome::Succeeded), - ); + 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, + }; + } - match (latest_failure, latest_success) { - (Some(failure), Some(success)) - if failure.timestamp.utc_millis == success.timestamp.utc_millis => - { - let instant = failure.timestamp.utc_millis; - PhaseDecision { - kind: PhaseDecisionKind::Contradictory, - decisive: facts - .iter() - .copied() - .filter(|fact| fact.timestamp.utc_millis == instant) - .collect(), - ordering_millis: instant, - } - } - (Some(failure), Some(success)) - if success.timestamp.utc_millis > failure.timestamp.utc_millis => - { - PhaseDecision { - kind: PhaseDecisionKind::Succeeded, - decisive: vec![success], - ordering_millis: success.timestamp.utc_millis, - } - } - (Some(failure), _) => PhaseDecision { - kind: PhaseDecisionKind::Failed, - decisive: vec![failure], - ordering_millis: failure.timestamp.utc_millis, + PhaseDecision { + kind: match latest.outcome { + FactOutcome::Succeeded => PhaseDecisionKind::Succeeded, + FactOutcome::Failed if latest.terminal => PhaseDecisionKind::Failed, + FactOutcome::Deferred => PhaseDecisionKind::Deferred, + FactOutcome::Failed => PhaseDecisionKind::UnusableTime, }, - (None, Some(success)) => PhaseDecision { - kind: PhaseDecisionKind::Succeeded, - decisive: vec![success], - ordering_millis: success.timestamp.utc_millis, - }, - (None, None) => { - let latest_deferred = latest_fact( - facts - .iter() - .copied() - .filter(|fact| fact.outcome == FactOutcome::Deferred), - ); - if let Some(deferred) = latest_deferred { - PhaseDecision { - kind: PhaseDecisionKind::Deferred, - decisive: vec![deferred], - ordering_millis: deferred.timestamp.utc_millis, - } - } else { - PhaseDecision { - kind: PhaseDecisionKind::UnusableTime, - decisive: facts.to_vec(), - ordering_millis: None, - } - } - } + decisive: vec![latest], + ordering_millis: instant, } } @@ -858,7 +824,7 @@ fn parse_fact( } let request_id = normalize_uuid(&token_value(message, "RequestId")?)?; - let policy_id = match token_value(message, "PolicyId") { + let policy_id = match validated_token_value(message, "PolicyId")? { Some(value) => Some(normalize_uuid(&value)?), None => None, }; @@ -895,17 +861,17 @@ fn parse_phase_outcome( let deferred = FactOutcome::Deferred; match producer { - "MP_GetAuth" if lowercase.starts_with("receive request succeeded") => { + "MP_GetAuth" if has_event_marker(&lowercase, "receive request succeeded") => { Some((SccmManagementPointPhase::ReceiveRequest, succeeded, false)) } - "MP_GetAuth" if lowercase.starts_with("authenticate succeeded") => { + "MP_GetAuth" if has_event_marker(&lowercase, "authenticate succeeded") => { Some((SccmManagementPointPhase::Authenticate, succeeded, false)) } - "MP_GetAuth" if lowercase.starts_with("authenticate failed terminal") => { + "MP_GetAuth" if has_event_marker(&lowercase, "authenticate failed terminal") => { Some((SccmManagementPointPhase::Authenticate, failed, true)) } "MP_CliReg" | "MP_RegistrationManager" - if lowercase.starts_with("register or identify succeeded") => + if has_event_marker(&lowercase, "register or identify succeeded") => { Some(( SccmManagementPointPhase::RegisterOrIdentify, @@ -914,49 +880,60 @@ fn parse_phase_outcome( )) } "MP_CliReg" | "MP_RegistrationManager" - if lowercase.starts_with("register or identify failed terminal") => + if has_event_marker(&lowercase, "register or identify failed terminal") => { Some((SccmManagementPointPhase::RegisterOrIdentify, failed, true)) } - "MP_Location" if lowercase.starts_with("resolve location succeeded") => Some(( + "MP_Location" if has_event_marker(&lowercase, "resolve location succeeded") => Some(( SccmManagementPointPhase::ResolveLocationOrPolicy, succeeded, false, )), - "MP_Location" if lowercase.starts_with("resolve location failed terminal") => Some(( - SccmManagementPointPhase::ResolveLocationOrPolicy, - failed, - true, - )), - "MP_GetPolicy" if lowercase.starts_with("resolve policy succeeded") => Some(( + "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 lowercase.starts_with("resolve policy failed terminal") => Some(( + "MP_GetPolicy" if has_event_marker(&lowercase, "resolve policy failed terminal") => Some(( SccmManagementPointPhase::ResolveLocationOrPolicy, failed, true, )), - "MP_GetPolicy" if lowercase.starts_with("respond deferred") => { + "MP_GetPolicy" if has_event_marker(&lowercase, "respond deferred") => { Some((SccmManagementPointPhase::Respond, deferred, false)) } - "MP_GetPolicy" if lowercase.starts_with("respond succeeded") => { + "MP_GetPolicy" if has_event_marker(&lowercase, "respond succeeded") => { Some((SccmManagementPointPhase::Respond, succeeded, false)) } - "MP_GetPolicy" if lowercase.starts_with("respond failed terminal") => { + "MP_GetPolicy" if has_event_marker(&lowercase, "respond failed terminal") => { Some((SccmManagementPointPhase::Respond, failed, true)) } - "MP_GetPolicy" if lowercase.starts_with("record outcome succeeded") => { + "MP_GetPolicy" if has_event_marker(&lowercase, "record outcome succeeded") => { Some((SccmManagementPointPhase::RecordOutcome, succeeded, false)) } - "MP_GetPolicy" if lowercase.starts_with("record outcome failed terminal") => { + "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] ") @@ -1000,9 +977,10 @@ fn is_supplemental_source(source: &SccmManagementPointSource) -> bool { .eq_ignore_ascii_case("mpcontrol.log")) } -fn token_value(message: &str, label: &str) -> Option { +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] @@ -1014,28 +992,36 @@ fn token_value(message: &str, label: &str) -> Option { } let remainder = &message[label_start + needle.len()..]; - if let Some(braced) = remainder.strip_prefix('{') { + 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(|character| { character.is_whitespace() || matches!(character, ',' | ';' | '&') }); - if exact_value_boundary && end > 0 { - return Some(braced[..end].to_owned()); + if !exact_value_boundary || end == 0 { + return None; } - continue; - } - - let end = remainder - .find(|character: char| { - character.is_whitespace() || matches!(character, ',' | ';' | '&') - }) - .unwrap_or(remainder.len()); - if end > 0 { - return Some(remainder[..end].to_owned()); + braced[..end].to_owned() + } else { + let end = remainder + .find(|character: char| { + character.is_whitespace() || matches!(character, ',' | ';' | '&') + }) + .unwrap_or(remainder.len()); + if end == 0 { + return None; + } + remainder[..end].to_owned() + }; + if value.replace(parsed).is_some() { + return None; } } - None + Some(value) +} + +fn token_value(message: &str, label: &str) -> Option { + validated_token_value(message, label)? } fn normalize_uuid(value: &str) -> Option { @@ -1109,6 +1095,25 @@ fn artifact_id_is_unique(bundle: &SccmManagementPointBundle, artifact_id: &str) == 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 + || (candidate.reference.artifact_id == evidence.reference.artifact_id + && candidate.reference.line_start == evidence.reference.line_start + && candidate.reference.line_end == evidence.reference.line_end) + }) + .take(2) + .count() + == 1 +} + fn safe_evidence_reference(reference: &SccmEvidenceRef) -> bool { safe_opaque_id(&reference.artifact_id) && safe_opaque_id(&reference.entry_id) @@ -1254,15 +1259,12 @@ fn append_rejected_observations( return; } - let evidence_by_id = bundle - .evidence - .iter() - .map(|evidence| (evidence.reference.entry_id.as_str(), evidence)) - .collect::>(); let unrelated = rejected.iter().any(|reference| { - evidence_by_id - .get(reference.entry_id.as_str()) - .is_some_and(|evidence| { + 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()) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs index d8c49be04..56e37717d 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -179,6 +179,10 @@ fn expected_transaction_projection(expected: &Value) -> Vec { .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"], @@ -187,7 +191,7 @@ fn expected_transaction_projection(expected: &Value) -> Vec { "classification": transaction["classification"], "confidence": transaction["confidence"], "coverageGapArtifactIds": transaction["coverageGapArtifactIds"], - "nextArtifactLogicalId": transaction["nextArtifact"]["logicalArtifactId"], + "nextArtifactLogicalIds": next_artifact_logical_ids, }) }) .collect() @@ -199,6 +203,15 @@ fn actual_transaction_projection(analysis: &Value) -> Vec { .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"], @@ -207,11 +220,7 @@ fn actual_transaction_projection(analysis: &Value) -> Vec { "classification": transaction["classification"], "confidence": transaction["confidence"], "coverageGapArtifactIds": transaction["coverageGapArtifactIds"], - "nextArtifactLogicalId": transaction["nextArtifacts"] - .as_array() - .and_then(|requests| requests.first()) - .map(|request| request["logicalArtifactId"].clone()) - .unwrap_or(Value::Null), + "nextArtifactLogicalIds": next_artifact_logical_ids, }) }) .collect() @@ -328,14 +337,21 @@ fn source_local_projection(value: &Value, actual: bool) -> Vec { .expect("source-local observations") .iter() .map(|observation| { - let next_logical_id = if actual { + let next_logical_ids = if actual { observation["nextArtifacts"] .as_array() - .and_then(|requests| requests.first()) - .map(|request| request["logicalArtifactId"].clone()) - .unwrap_or(Value::Null) + .map(|requests| { + requests + .iter() + .map(|request| request["logicalArtifactId"].clone()) + .collect::>() + }) + .unwrap_or_default() } else { - observation["nextArtifact"]["logicalArtifactId"].clone() + observation["nextArtifact"]["logicalArtifactId"] + .as_str() + .map(|_| vec![observation["nextArtifact"]["logicalArtifactId"].clone()]) + .unwrap_or_default() }; json!({ "observationId": observation["observationId"], @@ -343,7 +359,7 @@ fn source_local_projection(value: &Value, actual: bool) -> Vec { "classification": observation["classification"], "confidence": observation["confidence"], "correlationEligible": observation["correlationEligible"], - "nextArtifactLogicalId": next_logical_id, + "nextArtifactLogicalIds": next_logical_ids, }) }) .collect() @@ -1108,10 +1124,9 @@ fn management_point_event_markers_require_an_exact_delimiter() { .iter_mut() .find(|evidence| evidence.message.contains("Record outcome succeeded")) .expect("record outcome"); - outcome.message = outcome.message.replace( - "Record outcome succeeded", - "Record outcome succeededness", - ); + outcome.message = outcome + .message + .replace("Record outcome succeeded", "Record outcome succeededness"); assert_no_high_success( &analysis_value(&bundle), @@ -1134,10 +1149,7 @@ fn management_point_conflicting_evidence_identity_reuse_fails_closed() { "a8111111-1111-1111-1111-111111111111", "a8999999-9999-9999-9999-999999999999", ) - .replace( - "safe:client:mp-healthy-01", - "safe:client:mp-conflicting-99", - ); + .replace("safe:client:mp-healthy-01", "safe:client:mp-conflicting-99"); } bundle.evidence.extend(conflicting); From b45c4b610d001f6f0540d722212643cdc66b0a4e Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 10:03:24 -0400 Subject: [PATCH 153/422] fix(sccm): order equal-time DP evidence by provenance --- ...ver_distribution_point_fixture_contract.rs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs index dbb616e31..ae1f19957 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs @@ -1481,6 +1481,7 @@ fn validate_expected( let mut observed_after_terminal_failure = false; let mut cites_capped_evidence = false; let mut previous_utc = i64::MIN; + let mut previous_physical_order: Option<(String, u32)> = None; let mut previous_phase = 0usize; for observation in observations { let observation_id = @@ -1613,7 +1614,30 @@ fn validate_expected( "{transaction_id} evidence is not ordered by normalized UTC provenance" )); } + let current_physical_order = record + .reference + .line_start + .zip(record.reference.line_end) + .map(|(line_start, line_end)| { + (record.reference.artifact_id.as_str(), line_start, line_end) + }); + if utc == previous_utc + && previous_physical_order.as_ref().is_some_and( + |(previous_artifact_id, previous_line_end)| { + current_physical_order.is_none_or(|(artifact_id, line_start, _)| { + artifact_id != previous_artifact_id + || line_start <= *previous_line_end + }) + }, + ) + { + failures.push(format!( + "{transaction_id} equal-UTC evidence lacks immutable same-artifact line order" + )); + } previous_utc = utc; + previous_physical_order = current_physical_order + .map(|(artifact_id, _, line_end)| (artifact_id.to_owned(), line_end)); } if terminal_failure { observed_after_terminal_failure = true; @@ -3467,6 +3491,30 @@ fn equal_utc_same_artifact_recovery_cannot_be_reordered_before_terminal_failure( ); } +#[test] +fn equal_utc_same_artifact_forward_line_order_remains_usable() { + let temporary = temporary_scenario("healthy-package"); + let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); + let expected = read_json("healthy-package", "expected.json").expect("expected loads"); + let relative_path = manifest["artifacts"][2]["relativePath"] + .as_str() + .expect("provider artifact has a path") + .to_owned(); + + replace_fixture_text( + &temporary.root, + &relative_path, + r#" Date: Fri, 31 Jul 2026 10:35:31 -0400 Subject: [PATCH 154/422] test(sccm): expose management point evidence ambiguity --- .../tests/sccm_server_management_point.rs | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs index 56e37717d..fc67141da 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -1174,3 +1174,255 @@ fn management_point_conflicting_evidence_identity_reuse_fails_closed() { "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_cites_the_decisive_success() { + 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"); + + let counterpart = analysis["counterpartReadyFacts"] + .as_array() + .expect("counterpart facts") + .iter() + .find(|fact| fact["state"] == "succeeded") + .expect("successful counterpart"); + assert_eq!(counterpart["classification"], "success"); + assert_eq!( + counterpart["evidence"]["lineStart"], 4, + "the success handoff must cite the decisive successful record" + ); + 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" + ); +} From 4067dc10d0abe82179c0341fb27c683455672486 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 10:36:26 -0400 Subject: [PATCH 155/422] test(sccm): freeze hierarchy timestamp provenance gaps --- .../origin/equal-instant/replmgr.log | 2 + .../target/equal-instant/despool.log | 2 + ...rarchy_and_replication_fixture_contract.rs | 121 ++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-control/origin/equal-instant/replmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/healthy-link/evidence/server-hierarchy-transfer/target/equal-instant/despool.log 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-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/sccm_server_hierarchy_and_replication_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_hierarchy_and_replication_fixture_contract.rs index ee3a830ee..74f5d0e75 100644 --- 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 @@ -3797,6 +3797,127 @@ fn hierarchy_nonterminal_transactions_cannot_advertise_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" + ); + 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"); From d1c34470988ba38db9dceacaa12210158d9bfa9a Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 10:40:01 -0400 Subject: [PATCH 156/422] fix(sccm): preserve exact management point evidence --- .../sccm/server/windows/management_point.rs | 114 ++++++++++-------- 1 file changed, 62 insertions(+), 52 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs index 1380ff04b..c32d600ca 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs @@ -540,7 +540,8 @@ fn reduce_transaction( let finding = build_transaction_finding(&transaction, &decisive_facts, gap.as_ref(), &next_artifacts); - let counterpart_fact = build_counterpart_fact(&transaction, facts, &transaction_id, &key); + let counterpart_fact = + build_counterpart_fact(&transaction, &decisive_facts, &transaction_id, &key); Some(ReducedTransaction { transaction, @@ -756,7 +757,7 @@ fn build_transaction_finding( fn build_counterpart_fact( transaction: &SccmManagementPointTransaction, - facts: &[ManagementPointFact], + decisive_facts: &[&ManagementPointFact], transaction_id: &str, key: &SccmManagementPointKey, ) -> Option { @@ -771,15 +772,19 @@ fn build_counterpart_fact( return None; } - let fact = facts + let fact = decisive_facts .iter() - .filter(|fact| fact.policy_id.is_some()) - .filter(|fact| fact.phase <= transaction.phase) + .copied() + .filter(|fact| fact.phase == transaction.phase) .filter(|fact| { - transaction.state != SccmManagementPointState::Failed - || (fact.phase == transaction.phase - && fact.outcome == FactOutcome::Failed - && fact.terminal) + matches!( + (transaction.state, fact.outcome, fact.terminal), + ( + SccmManagementPointState::Succeeded, + FactOutcome::Succeeded, + _ + ) | (SccmManagementPointState::Failed, FactOutcome::Failed, true) + ) }) .filter(|fact| { matches!( @@ -819,7 +824,10 @@ fn parse_fact( } let message = evidence.message.as_str(); let (phase, outcome, terminal) = parse_phase_outcome(message, &source.producer)?; - if terminal && !has_nonzero_result(message) { + 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; } @@ -986,27 +994,23 @@ fn validated_token_value(message: &str, label: &str) -> Option> { || message[..label_start] .chars() .next_back() - .is_some_and(|character| !character.is_ascii_alphanumeric() && character != '_'); + .is_some_and(is_key_token_boundary); if !exact_label_boundary { - continue; + 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(|character| { - character.is_whitespace() || matches!(character, ',' | ';' | '&') - }); + 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(|character: char| { - character.is_whitespace() || matches!(character, ',' | ';' | '&') - }) + .find(is_key_token_boundary) .unwrap_or(remainder.len()); if end == 0 { return None; @@ -1020,6 +1024,10 @@ fn validated_token_value(message: &str, label: &str) -> Option> { 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)? } @@ -1037,19 +1045,17 @@ fn normalize_uuid(value: &str) -> Option { valid.then(|| value.to_ascii_lowercase()) } -fn has_nonzero_result(message: &str) -> bool { - let Some(value) = token_value(message, "Result") else { - return false; +fn validated_result_value(message: &str) -> Option> { + let Some(value) = validated_token_value(message, "Result")? else { + return Some(None); }; - let Some(hex) = value + let hex = value .strip_prefix("0x") - .or_else(|| value.strip_prefix("0X")) - else { - return false; - }; - hex.len() == 8 - && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) - && u32::from_str_radix(hex, 16).is_ok_and(|value| value != 0) + .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 { @@ -1105,15 +1111,29 @@ fn evidence_identity_is_unique( .filter(|candidate| { candidate.evidence_id == evidence.evidence_id || candidate.reference.entry_id == evidence.reference.entry_id - || (candidate.reference.artifact_id == evidence.reference.artifact_id - && candidate.reference.line_start == evidence.reference.line_start - && candidate.reference.line_end == evidence.reference.line_end) + || evidence_references_overlap(&candidate.reference, &evidence.reference) }) .take(2) .count() == 1 } +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 + ) +} + fn safe_evidence_reference(reference: &SccmEvidenceRef) -> bool { safe_opaque_id(&reference.artifact_id) && safe_opaque_id(&reference.entry_id) @@ -1156,28 +1176,18 @@ fn compare_references(left: &SccmEvidenceRef, right: &SccmEvidenceRef) -> Orderi } fn merge_references(references: impl IntoIterator) -> Vec { - let mut ranges: BTreeMap = BTreeMap::new(); - for reference in references { - let (Some(start), Some(end)) = (reference.line_start, reference.line_end) else { - continue; - }; - ranges - .entry(reference.artifact_id) - .and_modify(|range| { - range.0 = range.0.min(start); - range.1 = range.1.max(end); - }) - .or_insert((start, end)); - } - ranges + let mut references = references .into_iter() - .map(|(artifact_id, (line_start, line_end))| SccmEvidenceRef { - entry_id: format!("{artifact_id}:{line_start}-{line_end}"), - artifact_id, - line_start: Some(line_start), - line_end: Some(line_end), + .filter(|reference| { + matches!( + (reference.line_start, reference.line_end), + (Some(start), Some(end)) if start > 0 && end >= start + ) }) - .collect() + .collect::>(); + references.sort_by(compare_references); + references.dedup(); + references } fn physical_reference(source: &SccmManagementPointSource) -> Option { From da2b4ca2436bb70c2bb03f09c31f69f493f91930 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 10:41:22 -0400 Subject: [PATCH 157/422] fix(sccm): preserve hierarchy provenance ordering --- Cargo.lock | 1 + crates/cmtraceopen-parser/Cargo.toml | 3 + .../hierarchy_and_replication/README.md | 5 + ...rarchy_and_replication_fixture_contract.rs | 168 +++++++++++++++--- 4 files changed, 153 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2a621b0db..d8c3acd1f 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..51f826871 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" + +[dev-dependencies] +sha2 = "0.11" 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 index f3535d4a1..dab7181bb 100644 --- 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 @@ -12,6 +12,11 @@ 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 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 index 74f5d0e75..31d33c0c1 100644 --- 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 @@ -1,12 +1,14 @@ 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, + SccmRole, SccmRotation, SccmTimeOrderingState, SccmTimestamp, }; use serde_json::Value; +use sha2::{Digest, Sha256}; const SCENARIOS: &[&str] = &[ "absent-remote-source", @@ -109,6 +111,35 @@ fn safe_server_handle(value: &str) -> bool { .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() @@ -433,6 +464,39 @@ struct HierarchyCandidateKey { 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 { @@ -449,6 +513,7 @@ struct HierarchyCandidateFact { rotation_lineage_id: String, line_start: u32, line_end: u32, + timestamp: HierarchyCandidateTimestamp, } #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] @@ -565,15 +630,22 @@ fn hierarchy_candidate_groups( disposition: disposition.to_owned(), terminal, artifact_id: artifact_id.to_owned(), - producer_host_handle: producer_host_handle.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: path_fingerprint.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); } @@ -616,6 +688,49 @@ fn evidence_reference_key(reference: &Value) -> Option<(String, u32, u32)> { (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, @@ -1517,6 +1632,7 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val )), } for transaction in transactions.into_iter().flatten() { + let mut prior_timestamp = None; if !object_has_only( transaction, &[ @@ -1721,6 +1837,15 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val "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 { @@ -2383,7 +2508,7 @@ fn hierarchy_transactions_require_exact_keys_topology_time_and_citations() { } let mut prior_phase = 0usize; - let mut prior_utc = i64::MIN; + let mut prior_timestamp = None; let mut cited_terminal = false; let mut cited_records = BTreeSet::new(); for observation in observations { @@ -2478,20 +2603,12 @@ fn hierarchy_transactions_require_exact_keys_topology_time_and_citations() { } match transaction["timestampOrdering"].as_str() { Some("usable") => { - if record.timestamp.ordering_state - != SccmTimeOrderingState::NormalizedUtc - || record.timestamp.utc_millis.is_none() - || record - .timestamp - .utc_millis - .is_some_and(|utc| utc < prior_utc) - { - failures.push(format!( - "{scenario}/{transaction_id}: unusable or reversed time" - )); - } - if let Some(utc) = record.timestamp.utc_millis { - prior_utc = utc; + if let Err(error) = advance_usable_timestamp_sequence( + &mut prior_timestamp, + artifact_id, + record, + ) { + failures.push(format!("{scenario}/{transaction_id}: {error}")); } } Some("unusableInvalidOffset") => { @@ -3880,6 +3997,11 @@ fn hierarchy_identity_bearing_safe_looking_values_are_domain_separated_before_se 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"), @@ -3892,9 +4014,8 @@ 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" - ); + 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); @@ -3907,9 +4028,8 @@ fn hierarchy_equal_utc_requires_same_artifact_and_forward_physical_lines() { 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" - ); + 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!( From 65b0bb9c657ecab31af5e031c47592db5cc1d79c Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 10:43:48 -0400 Subject: [PATCH 158/422] test(sccm): bind MP handoff evidence to policy key --- .../tests/sccm_server_management_point.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs index fc67141da..af4a0c823 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -1265,7 +1265,7 @@ fn management_point_exact_labels_reject_hyphenated_prefixes() { } #[test] -fn successful_counterpart_handoff_cites_the_decisive_success() { +fn successful_counterpart_handoff_requires_the_decisive_fact_to_prove_the_policy_key() { let mut bundle = load_bundle("healthy-policy"); let deferred = bundle .evidence @@ -1310,16 +1310,25 @@ fn successful_counterpart_handoff_cites_the_decisive_success() { assert_eq!(transaction["phase"], "recordOutcome"); assert_eq!(transaction["confidence"], "high"); - let counterpart = analysis["counterpartReadyFacts"] + 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("successful counterpart"); + .expect("baseline successful counterpart"); assert_eq!(counterpart["classification"], "success"); assert_eq!( counterpart["evidence"]["lineStart"], 4, - "the success handoff must cite the decisive successful record" + "the policy-bearing decisive success must remain correlation eligible" ); assert!( counterpart["terminalEvidence"].is_null(), From 187c529b972e25d1c0a87e74055e546097b3f440 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 10:44:42 -0400 Subject: [PATCH 159/422] fix(sccm): require cited MP policy identity --- .../src/sccm/server/windows/management_point.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs index c32d600ca..171453a6b 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs @@ -775,6 +775,7 @@ fn build_counterpart_fact( 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!( From 1bbd5a2ac970901aa62c7d10b8b1ba167c3bf4e2 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 10:51:34 -0400 Subject: [PATCH 160/422] test(sccm): reject coherent unknown management profiles --- ...sccm_client_management_fixture_contract.rs | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 425e00aba..97df48aaa 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -2762,16 +2762,43 @@ fn version_and_physical_identity_provenance_mutations_fail_closed() { .expect("the bounded synthetic source contract remains valid"); let mut accepted = Vec::new(); - let mut malformed_version = script_manifest.clone(); - malformed_version["artifacts"][0]["sourceVersion"] = + let coherent_unknown_profile_expected = |artifact_id: &str| { + let mut expected = script_expected.clone(); + expected["extractionProfile"]["selectionState"] = + Value::String("unknownProfile".to_owned()); + expected["sourceLocalObservations"] = serde_json::json!([{ + "observationId": format!("{artifact_id}-unknown-profile"), + "kind": "unknownProfile", + "claim": "The synthetic source version cannot select a validated extraction profile.", + "confidenceCeiling": "low", + "correlationEligible": false, + "artifactIds": [artifact_id], + }]); + expected + }; + + let mut malformed_transaction_version = script_manifest.clone(); + malformed_transaction_version["artifacts"][0]["sourceVersion"] = Value::String("5.00.TEST.UNKNOWN".to_owned()); if mutation_was_accepted( "script-success", &script_root, - &malformed_version, - &script_expected, + &malformed_transaction_version, + &coherent_unknown_profile_expected("script-success-current"), + ) { + accepted.push("malformed transaction source version retained high confidence"); + } + + let mut malformed_ownership_version = script_manifest.clone(); + malformed_ownership_version["artifacts"][1]["sourceVersion"] = + Value::String("5.00.TEST.UNKNOWN".to_owned()); + if mutation_was_accepted( + "script-success", + &script_root, + &malformed_ownership_version, + &coherent_unknown_profile_expected("script-success-owner"), ) { - accepted.push("malformed source version retained selected profile"); + accepted.push("malformed ownership source version retained high confidence"); } let mut leaking_fingerprint = script_manifest.clone(); From b3a35ceeb55ebeb384335fcbe886090a3b079ff6 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 10:55:18 -0400 Subject: [PATCH 161/422] fix(sccm): require canonical management source versions --- .../tests/sccm_client_management_fixture_contract.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 97df48aaa..78c90794a 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -1256,7 +1256,7 @@ fn validate_contract( if ownership_records.iter().any(|record| { record.ordering_state != SccmTimeOrderingState::NormalizedUtc || record.timestamp.is_none() - || !record.source_version.starts_with("5.00.TEST.") + || !source_version_matches_selected_profile(record.source_version.as_str()) }) { return Err( "ownership classification lacks usable timestamp/profile provenance".to_owned(), @@ -1496,7 +1496,7 @@ fn validate_contract( if records.iter().any(|record| { record.ordering_state != SccmTimeOrderingState::NormalizedUtc || record.timestamp.is_none() - || !record.source_version.starts_with("5.00.TEST.") + || !source_version_matches_selected_profile(record.source_version.as_str()) }) { return Err(format!( "{transaction_id} lacks usable time/profile provenance" From 6fec19f1f3bd5b02fea2ebbaaaba5c7d209c7e22 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:02:13 -0400 Subject: [PATCH 162/422] test(sccm): define pure server intake behavior --- .../tests/sccm_server_intake.rs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/sccm_server_intake.rs 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..ee1380f58 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -0,0 +1,112 @@ +use cmtraceopen_parser::sccm::server::windows::{ + assess_server_intake, SccmServerArtifactPayload, +}; +use cmtraceopen_parser::sccm::{SccmCoverageState, SccmRole}; +use serde_json::Value; +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) +} + +#[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].source_id, + "server-dp-distribution" + ); + + let (unsorted_manifest, unsorted_payloads) = load_bundle("unsorted-manifest"); + let unsorted = + assess_server_intake(&unsorted_manifest, &unsorted_payloads).expect("bundle is assessed"); + assert_eq!( + serde_json::to_vec(&complete).expect("assessment serializes"), + serde_json::to_vec(&unsorted).expect("assessment serializes"), + "manifest order must not affect normalized output" + ); +} From c78aebe69aab4c672b8193fa9312b31a4d146c72 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:03:10 -0400 Subject: [PATCH 163/422] test(sccm): compare reordered server manifests directly --- .../cmtraceopen-parser/tests/sccm_server_intake.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index ee1380f58..84de526bc 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -104,9 +104,20 @@ fn server_intake_normalizes_role_coverage_and_logical_records() { 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(&complete).expect("assessment serializes"), serde_json::to_vec(&unsorted).expect("assessment serializes"), + serde_json::to_vec(&reordered).expect("assessment serializes"), "manifest order must not affect normalized output" ); } From 840f96f21e2060f729941c3e5cdb3948394cb66a Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:03:35 -0400 Subject: [PATCH 164/422] test(sccm): reuse shared server artifact requests --- crates/cmtraceopen-parser/tests/sccm_server_intake.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index 84de526bc..86b6bb781 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -97,7 +97,7 @@ fn server_intake_normalizes_role_coverage_and_logical_records() { assert!(absent.findings.is_empty()); assert_eq!(absent.next_artifact_requests.len(), 1); assert_eq!( - absent.next_artifact_requests[0].source_id, + absent.next_artifact_requests[0].logical_id, "server-dp-distribution" ); From 43cb1f97e8168583d2f9a254ed8d6fc0840de422 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:10:34 -0400 Subject: [PATCH 165/422] test(sccm): cover task sequence review regressions --- ...m_client_task_sequence_fixture_contract.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) 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 index 8aaa63a7e..3c6061739 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -2266,6 +2266,55 @@ fn adversarial_contract_mutations_fail_closed() { 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 coherent_review_mutations_fail_closed() { let mut accepted = Vec::new(); From c8e597e25d89e9e8c390748f90faa1c2cfa853d5 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:11:24 -0400 Subject: [PATCH 166/422] fix(sccm): bind task sequence evidence claims --- ...m_client_task_sequence_fixture_contract.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) 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 index 3c6061739..fe52c9e5e 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -1173,6 +1173,13 @@ fn validate_contract( )); } } + 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() @@ -1254,6 +1261,27 @@ fn validate_contract( "{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() @@ -1320,6 +1348,19 @@ fn validate_contract( .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) From 7807c26d33646d789d31dc13476e5b1e1fc1678e Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:18:50 -0400 Subject: [PATCH 167/422] feat(sccm): assess pure server intake coverage --- crates/cmtraceopen-parser/src/sccm/mod.rs | 1 + .../cmtraceopen-parser/src/sccm/server/mod.rs | 1 + .../src/sccm/server/windows/catalog.rs | 156 +++ .../src/sccm/server/windows/intake.rs | 893 ++++++++++++++++++ .../src/sccm/server/windows/mod.rs | 5 + .../tests/sccm_server_intake.rs | 4 +- 6 files changed, 1057 insertions(+), 3 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/server/mod.rs create mode 100644 crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs create mode 100644 crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs create mode 100644 crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs diff --git a/crates/cmtraceopen-parser/src/sccm/mod.rs b/crates/cmtraceopen-parser/src/sccm/mod.rs index d5f1b55d3..0535c8a06 100644 --- a/crates/cmtraceopen-parser/src/sccm/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/mod.rs @@ -5,6 +5,7 @@ mod ingest; mod keys; pub mod models; mod rotation; +pub mod server; mod signals; pub use catalog::*; 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..47252c72a --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs @@ -0,0 +1,156 @@ +use crate::sccm::{classify_artifact_name, SccmArtifactFamily, SccmRole, SccmSourceCatalogEntry}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SccmServerSourceKind { + CcmLog, + IisW3c, + StructuredSupplement, +} + +#[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], + 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"], + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-status", + producer_role: SccmRole::SiteServer, + workflow_subject_role: None, + logical_names: &["statmgr", "statesys"], + 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"], + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-mp-policy", + producer_role: SccmRole::ManagementPoint, + workflow_subject_role: None, + logical_names: &["mpGetPolicy", "mpLocation", "mpcontrol"], + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-mp-iis", + producer_role: SccmRole::ManagementPoint, + workflow_subject_role: None, + logical_names: &[], + 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"], + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-dp-distribution", + producer_role: SccmRole::DistributionPoint, + workflow_subject_role: None, + logical_names: &["smsDpProv", "pullDp"], + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-sup-sync", + producer_role: SccmRole::SiteServer, + workflow_subject_role: Some(SccmRole::SoftwareUpdatePoint), + logical_names: &["wcm", "wsyncmgr"], + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, + SccmServerSourceSpec { + source_id: "server-sup-sync", + producer_role: SccmRole::SoftwareUpdatePoint, + workflow_subject_role: None, + logical_names: &["wsusCtrl", "supSetup"], + source_kind: SccmServerSourceKind::CcmLog, + supplemental: false, + }, +]; + +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 { + 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-mp-auth" | "server-mp-policy" | "server-mp-iis" => { + SccmArtifactFamily::ManagementPoint + } + "server-dp-distribution" => SccmArtifactFamily::DistributionPoint, + "server-sup-sync" => SccmArtifactFamily::SoftwareUpdatePoint, + _ => return None, + }) +} + +fn source_kind_matches(expected: SccmServerSourceKind, actual: &str) -> bool { + matches!( + (expected, actual), + (SccmServerSourceKind::CcmLog, "ccmLog") + | (SccmServerSourceKind::IisW3c, "iisW3c") + | ( + SccmServerSourceKind::StructuredSupplement, + "structuredSupplement" + ) + ) +} 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..c44d497a1 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -0,0 +1,893 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::{DateTime, SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use crate::sccm::{ + normalize_ccm_artifact, SccmArtifact, SccmArtifactFamily, SccmArtifactRequest, + SccmCoverageState, SccmEvidence, SccmFinding, SccmRole, SccmRotation, +}; + +use super::catalog::{classify_declared_server_source, expected_family, SccmServerSourceKind}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SccmServerArtifactPayload { + pub manifest_artifact_id: String, + pub bytes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +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, +} + +#[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, +} + +#[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 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 parser_eligible: 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, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmServerCoverage { + pub producer_role: SccmRole, + pub workflow_subject_role: 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, +} + +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 { + 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); + } + + let topology = normalize_topology(&manifest)?; + 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 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, + &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<(String, String, String, String), SccmServerCoverage> = + BTreeMap::new(); + let mut request_keys = BTreeSet::new(); + let mut next_artifact_requests = Vec::new(); + + for prepared_artifact in prepared { + let artifact = prepared_artifact.assessment; + let coverage_key = ( + role_sort_key(&artifact.producer_role).to_owned(), + artifact.source_id.clone(), + artifact + .workflow_subject_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default() + .to_owned(), + coverage_sort_key(&artifact.state).to_owned(), + ); + 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(), + workflow_subject_role: artifact.workflow_subject_role.clone(), + source_id: artifact.source_id.clone(), + state: artifact.state.clone(), + artifact_ids: vec![artifact.artifact_id.clone()], + }); + + if let Some(request) = request_for_gap(&artifact) { + 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(), + )) + }); + + Ok(SccmServerIntakeAssessment { + schema_version: 1, + topology, + artifacts, + coverage, + evidence, + findings: Vec::new(), + next_artifact_requests, + }) +} + +struct PreparedArtifact { + assessment: SccmServerArtifactAssessment, + evidence: Vec, +} + +impl PreparedArtifact { + fn sort_key(&self) -> (&str, &str, &str, String, &str, &str) { + ( + role_sort_key(&self.assessment.producer_role), + self.assessment.source_id.as_str(), + 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 normalize_topology( + manifest: &RawServerManifest, +) -> Result { + let site_handle = if manifest.synthetic_fixture { + if manifest.topology.site_code != "LAB" + || !manifest.topology.capture_host.starts_with("LAB-") + || !manifest + .topology + .capture_host + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'-') + { + 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)) + { + 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); + } + + Ok(SccmServerTopologyAssessment { + capture_host_handle, + site_handle, + roles_observed, + }) +} + +fn normalize_artifact( + artifact: RawServerArtifact, + synthetic_fixture: bool, + roles_observed: &[SccmRole], + relative_paths: &mut BTreeSet, + payload_by_id: &BTreeMap<&str, &[u8]>, +) -> Result { + if !safe_manifest_artifact_id(&artifact.artifact_id, synthetic_fixture) + || !safe_source_id(&artifact.source_id) + || !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", + ) + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + + let producer_is_observed = roles_observed.contains(&artifact.producer_role); + let unsupported_unknown = artifact.capture_state == SccmCoverageState::Unsupported + && artifact.producer_role == SccmRole::Unknown("unclassified".to_owned()); + if (!producer_is_observed && !unsupported_unknown) + || (producer_is_observed && !is_declared_server_role(&artifact.producer_role)) + { + 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, parser_eligible) = + if let Some((spec, classified)) = classification { + 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(classified.basename), declared_rotation, true) + } else { + (family, None, None, false) + } + } else if unsupported_unknown { + ( + SccmArtifactFamily::Unknown("unsupported".to_owned()), + None, + None, + false, + ) + } else { + return Err(SccmServerIntakeError::InvalidArtifact); + }; + + 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), + }; + 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.capture_state, + relative_paths, + )?; + let bytes = validate_payload_contract(&artifact, relative_path.as_deref(), payload_by_id)?; + let profile_eligible = artifact + .source_version + .as_deref() + .is_some_and(|version| source_version_is_profile_eligible(version, synthetic_fixture)); + + let mut evidence = Vec::new(); + if artifact.capture_state == SccmCoverageState::Captured && parser_eligible { + let bytes = bytes.ok_or(SccmServerIntakeError::MissingPayload)?; + if artifact.encoding.as_deref() != Some("utf-8") { + return Err(SccmServerIntakeError::InvalidPayloadEncoding); + } + let content = std::str::from_utf8(bytes) + .map_err(|_| SccmServerIntakeError::InvalidPayloadEncoding)?; + evidence = normalize_ccm_artifact( + SccmArtifact { + artifact_id: artifact.artifact_id.clone(), + display_name: original_basename + .clone() + .ok_or(SccmServerIntakeError::InvalidArtifact)?, + original_path: None, + host: artifact.producer_host_handle.clone(), + role: artifact.producer_role.clone(), + configmgr_version: artifact.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, + ); + } + + 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: if unsupported_unknown { + "unsupported".to_owned() + } else { + artifact.source_id + }, + family, + original_basename, + rotation, + rotation_lineage_handle: artifact.rotation.lineage_id, + state: artifact.capture_state, + configured_path_state, + configured_path_class, + path_fingerprint: artifact.configured_path_provenance.path_fingerprint, + source_version: artifact.source_version, + profile_eligible, + collected_at_utc, + relative_path, + bytes_copied: artifact.bytes_copied, + parser_eligible, + }, + evidence, + }) +} + +fn validate_payload_contract<'a>( + artifact: &RawServerArtifact, + relative_path: Option<&str>, + payload_by_id: &'a BTreeMap<&str, &'a [u8]>, +) -> Result, SccmServerIntakeError> { + let payload = payload_by_id.get(artifact.artifact_id.as_str()).copied(); + let physical = matches!( + artifact.capture_state, + SccmCoverageState::Captured | SccmCoverageState::Capped + ); + if physical { + 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 + } + _ => false, + }; + if !valid_limit || artifact.encoding.is_none() { + return Err(SccmServerIntakeError::InvalidArtifact); + } + return Ok(Some(payload)); + } + + if payload.is_some() + || relative_path.is_some() + || artifact.bytes_copied != 0 + || artifact.encoding.is_some() + || artifact.collection_limit.is_some() + { + return Err(SccmServerIntakeError::UnexpectedPayload); + } + Ok(None) +} + +fn validate_relative_path( + relative_path: Option, + original_basename: Option<&str>, + state: &SccmCoverageState, + relative_paths: &mut BTreeSet, +) -> Result, SccmServerIntakeError> { + let physical = matches!( + state, + SccmCoverageState::Captured | SccmCoverageState::Capped + ); + if !physical { + if relative_path.is_some() { + return Err(SccmServerIntakeError::InvalidArtifact); + } + return Ok(None); + } + let relative_path = relative_path.ok_or(SccmServerIntakeError::InvalidArtifact)?; + if !relative_path.starts_with("evidence/sccm/server/") + || relative_path.starts_with('/') + || 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'.')) + }) + || original_basename.is_none_or(|basename| !relative_path.ends_with(basename)) + || !relative_paths.insert(relative_path.clone()) + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + Ok(Some(relative_path)) +} + +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) + .ok_or(SccmServerIntakeError::InvalidArtifact)?; + SccmRotation::Timestamped(timestamp.to_owned()) + } + _ => return Ok(None), + }; + Ok(Some(parsed)) +} + +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 request_for_gap(artifact: &SccmServerArtifactAssessment) -> Option { + let reason = match artifact.state { + SccmCoverageState::Absent => "source was absent; role outcome remains unknown", + SccmCoverageState::AccessDenied => "source access was denied; role outcome remains unknown", + SccmCoverageState::Capped => "source was capped; terminal role evidence is incomplete", + SccmCoverageState::ParseFailed => { + "source parsing failed; terminal role evidence is unavailable" + } + _ => return None, + }; + Some(SccmArtifactRequest { + logical_id: artifact.source_id.clone(), + role: artifact + .workflow_subject_role + .clone() + .unwrap_or_else(|| artifact.producer_role.clone()), + reason: reason.to_owned(), + }) +} + +fn source_version_is_profile_eligible(value: &str, synthetic_fixture: bool) -> bool { + if synthetic_fixture && value == "5.00.TEST" { + 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 { + return safe_synthetic_identifier(value); + } + opaque_sha256_handle(value, "cmtraceopen.artifact.sha256.v1:") +} + +fn safe_synthetic_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) +} + +fn safe_source_id(value: &str) -> bool { + matches!( + value, + "server-sitecomp" + | "server-status" + | "server-mp-auth" + | "server-mp-policy" + | "server-mp-iis" + | "server-dp-distribution" + | "server-sup-sync" + | "unknown-db-supplement" + ) +} + +fn safe_lineage_id(value: &str, synthetic_fixture: bool) -> bool { + if synthetic_fixture { + return safe_synthetic_identifier(value); + } + opaque_sha256_handle(value, "cmtraceopen.lineage.sha256.v1:") +} + +fn safe_path_fingerprint(value: &str, synthetic_fixture: bool) -> bool { + if synthetic_fixture { + return value.strip_prefix("synthetic:path:").is_some_and(|suffix| { + !suffix.is_empty() + && suffix.len() <= 96 + && suffix + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }); + } + 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.is_empty() && value.len() <= 1024 && !value.chars().any(char::is_control) +} + +fn safe_optional_handle(value: Option<&str>, synthetic_fixture: bool, domain: &str) -> bool { + let Some(value) = value else { + return true; + }; + if synthetic_fixture { + return value + .strip_prefix(&format!("synthetic:{domain}:")) + .is_some_and(|suffix| { + !suffix.is_empty() + && suffix.len() <= 96 + && suffix.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' + }) + }); + } + opaque_sha256_handle(value, &format!("cmtraceopen.{domain}.sha256.v1:")) +} + +fn opaque_sha256_handle(value: &str, prefix: &str) -> bool { + value.strip_prefix(prefix).is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +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 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::LoUnderscore) => "0-lo-underscore".to_owned(), + Some(SccmRotation::Numbered(value)) => format!("1-numbered-{value:010}"), + Some(SccmRotation::Timestamped(value)) => format!("2-timestamped-{value}"), + Some(SccmRotation::Current) => "3-current".to_owned(), + Some(SccmRotation::Unknown(_)) => "4-unknown".to_owned(), + None => "5-not-applicable".to_owned(), + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawServerManifest { + sccm_manifest_version: u32, + #[serde(default)] + synthetic_fixture: bool, + bundle_role: String, + topology: RawServerTopology, + artifacts: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawServerTopology { + capture_host: String, + site_code: String, + roles_observed: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawServerArtifact { + artifact_id: String, + producer_role: SccmRole, + producer_host_handle: Option, + workflow_subject: Option, + source_id: String, + source_kind: String, + source_version: Option, + original_path: String, + original_basename: String, + configured_path_provenance: RawConfiguredPathProvenance, + rotation: RawServerRotation, + capture_state: SccmCoverageState, + encoding: Option, + collection_limit: Option, + collected_utc: String, + relative_path: Option, + bytes_copied: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawWorkflowSubject { + role: SccmRole, + instance_handle: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawConfiguredPathProvenance { + state: String, + path_class: Option, + path_fingerprint: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawServerRotation { + kind: String, + value: Option, + lineage_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawCollectionLimit { + byte_limit: u64, + limit_applied: bool, +} 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..1a2a7dfc5 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs @@ -0,0 +1,5 @@ +mod catalog; +mod intake; + +pub use catalog::*; +pub use intake::*; diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index 86b6bb781..86d40e6e8 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -1,6 +1,4 @@ -use cmtraceopen_parser::sccm::server::windows::{ - assess_server_intake, SccmServerArtifactPayload, -}; +use cmtraceopen_parser::sccm::server::windows::{assess_server_intake, SccmServerArtifactPayload}; use cmtraceopen_parser::sccm::{SccmCoverageState, SccmRole}; use serde_json::Value; use std::path::{Path, PathBuf}; From 225c78f0df7a0236388518bfb2c3f9c517f2dbe9 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:21:51 -0400 Subject: [PATCH 168/422] test(sccm): cover management contract review gaps --- ...sccm_client_management_fixture_contract.rs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 78c90794a..b79367a9d 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -2845,3 +2845,80 @@ fn version_and_physical_identity_provenance_mutations_fail_closed() { "version or physical provenance mutations were accepted: {accepted:?}" ); } + +#[test] +fn profile_citation_and_public_observation_mutations_fail_closed() { + let mut accepted = Vec::new(); + + let (mixed_root, mut mixed_manifest, mut mixed_expected) = load_contract("mixed-unrelated"); + let formerly_unknown = mixed_manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["artifactId"] == "mixed-owner-unknown") + .expect("mixed corpus has the unknown-profile artifact"); + formerly_unknown["sourceVersion"] = Value::String("5.00.TEST.3260".to_owned()); + mixed_expected["sourceLocalObservations"] + .as_array_mut() + .expect("observations are an array") + .retain(|observation| observation["kind"] != "unknownProfile"); + if mutation_was_accepted( + "mixed-unrelated", + &mixed_root, + &mixed_manifest, + &mixed_expected, + ) { + accepted.push("stale mixed profile state after the unknown version was removed"); + } + + let (script_root, script_manifest, script_expected) = load_contract("script-success"); + let mut duplicate_ownership = script_expected.clone(); + let ownership_reference = duplicate_ownership["ownership"]["evidence"][0].clone(); + duplicate_ownership["ownership"]["evidence"] + .as_array_mut() + .expect("ownership evidence is an array") + .push(ownership_reference); + if mutation_was_accepted( + "script-success", + &script_root, + &script_manifest, + &duplicate_ownership, + ) { + accepted.push("duplicate ownership evidence citation"); + } + + let mut duplicate_transaction = script_expected; + let transaction_reference = duplicate_transaction["transactions"][0]["evidence"][0].clone(); + duplicate_transaction["transactions"][0]["evidence"] + .as_array_mut() + .expect("transaction evidence is an array") + .push(transaction_reference); + if mutation_was_accepted( + "script-success", + &script_root, + &script_manifest, + &duplicate_transaction, + ) { + accepted.push("duplicate transaction evidence citation"); + } + + let (software_center_root, software_center_manifest, mut software_center_expected) = + load_contract("software-center-observed"); + software_center_expected["sourceLocalObservations"][0]["claim"] = Value::String( + r"Observed C:\Users\RealUser and real.user@customer.example remain source local." + .to_owned(), + ); + if mutation_was_accepted( + "software-center-observed", + &software_center_root, + &software_center_manifest, + &software_center_expected, + ) { + accepted.push("identity-bearing public observation claim"); + } + + assert!( + accepted.is_empty(), + "profile, citation, or public observation mutations were accepted: {accepted:?}" + ); +} From 6a3b8c0ee6b75c542b2b171098a0a04f1b6969ce Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:22:48 -0400 Subject: [PATCH 169/422] test(sccm): cover task sequence review follow-ups --- ...m_client_task_sequence_fixture_contract.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) 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 index fe52c9e5e..35aa76e24 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -2356,6 +2356,70 @@ fn transaction_evidence_order_is_canonical_and_unique() { 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 coherent_review_mutations_fail_closed() { let mut accepted = Vec::new(); From f4f313dc353dcdb9375144cb92931b684891e85c Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:23:13 -0400 Subject: [PATCH 170/422] fix(sccm): harden management contract projection --- ...sccm_client_management_fixture_contract.rs | 71 +++++++++++++++++-- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index b79367a9d..1546d46fb 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -352,6 +352,50 @@ fn source_version_matches_selected_profile(value: &str) -> bool { .is_some_and(|suffix| suffix.len() == 4 && suffix.bytes().all(|byte| byte.is_ascii_digit())) } +fn public_observation_claim_is_safe(value: &str) -> bool { + if value.len() > 512 + || value.chars().any(char::is_control) + || value.contains(['\\', '@']) + || value.contains("://") + { + return false; + } + + let lower = value.to_ascii_lowercase(); + if lower.contains("/users/") + || lower.contains("/home/") + || lower.contains("s-1-5-") + || lower.contains("c:/") + { + return false; + } + + !value.split_ascii_whitespace().any(|token| { + let token = token.trim_matches(|character: char| { + matches!( + character, + ',' | ';' | ':' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '"' | '\'' + ) + }); + let mut labels = token.split('.'); + let Some(first) = labels.next() else { + return false; + }; + let remaining = labels.collect::>(); + !first.is_empty() + && !remaining.is_empty() + && remaining.iter().all(|label| { + !label.is_empty() + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) + && remaining.last().is_some_and(|suffix| { + suffix.len() >= 2 && suffix.bytes().all(|b| b.is_ascii_alphabetic()) + }) + }) +} + fn string_array(value: &Value, context: &str) -> Result, String> { value .as_array() @@ -1142,7 +1186,11 @@ fn validate_contract( let required_profile_selection = match workflow { "softwareCenter" => "unsupportedCandidate", - "mixed" => "mixedUnknownAndInvalid", + "mixed" + if !unknown_version_artifacts.is_empty() && !invalid_offset_artifacts.is_empty() => + { + "mixedUnknownAndInvalid" + } _ if !unknown_version_artifacts.is_empty() => "unknownProfile", _ => "selected", }; @@ -1240,8 +1288,12 @@ fn validate_contract( } let mut sorted_ownership_refs = ownership_ref_order.clone(); sorted_ownership_refs.sort(); - if ownership_ref_order != sorted_ownership_refs { - return Err("ownership evidence is not deterministically sorted".to_owned()); + 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 ownership_class != "UnknownOwnership" { let workload = required_string(ownership, "workload", "ownership")?; @@ -1461,9 +1513,13 @@ fn validate_contract( } let mut sorted_transaction_refs = transaction_ref_order.clone(); sorted_transaction_refs.sort(); - if transaction_ref_order != sorted_transaction_refs { + 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 not sorted" + "{transaction_id} evidence references are duplicated or not sorted" )); } let records = evidence_records(scenario_root, &artifacts_by_id, &transaction["evidence"])?; @@ -1729,6 +1785,11 @@ fn validate_contract( "{observation_id} makes an unsupported causal claim" )); } + if !public_observation_claim_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"), From c33669d110ef46e995a9fb2537ba7352ba659796 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:24:08 -0400 Subject: [PATCH 171/422] fix(sccm): close task sequence review follow-ups --- ...m_client_task_sequence_fixture_contract.rs | 24 +++++++---- .../issue-324-client-task-sequence-corpus.md | 41 +++++++++++++------ 2 files changed, 44 insertions(+), 21 deletions(-) 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 index 35aa76e24..05edb5521 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -729,7 +729,7 @@ fn validate_manifest_and_storage( "{scenario}/{artifact_id}: _SMSTSLogPath is not observed in this physical artifact" )); } - None if !fragment_complete && observed_paths.is_empty() => {} + None if observed_paths.is_empty() => {} None => { return Err(format!( "{scenario}/{artifact_id}: physical _SMSTSLogPath presence/absence is not declared exactly" @@ -986,10 +986,12 @@ fn validate_contract( )); } - let missing_physical_path_evidence = artifacts + let incomplete_fragments_missing_path_evidence = artifacts .iter() .filter(|artifact| { - artifact["captureState"] == "captured" && artifact["smstsLogPathEvidence"].is_null() + artifact["captureState"] == "captured" + && artifact["rotation"]["fragmentComplete"] == false + && artifact["smstsLogPathEvidence"].is_null() }) .map(|artifact| { artifact["artifactId"] @@ -1127,9 +1129,9 @@ fn validate_contract( )); } } - if reconstructed_missing_path_evidence != missing_physical_path_evidence { + if reconstructed_missing_path_evidence != incomplete_fragments_missing_path_evidence { return Err(format!( - "{scenario}: every missing per-artifact _SMSTSLogPath must have one explicit logical reconstruction" + "{scenario}: every incomplete fragment missing _SMSTSLogPath must have one explicit logical reconstruction" )); } @@ -1562,9 +1564,11 @@ fn validate_contract( 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 { + if observation_ids != sorted_observation_ids + || observation_ids.iter().collect::>().len() != observation_ids.len() + { return Err(format!( - "{scenario}: source-local observations are not sorted" + "{scenario}: source-local observation IDs must be unique and sorted" )); } for observation in observations { @@ -1594,8 +1598,10 @@ fn validate_contract( 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 { - return Err(format!("{scenario}: finding IDs are not sorted")); + 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() diff --git a/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md b/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md index d87d113dd..fa46cc606 100644 --- a/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md +++ b/docs/sccm/preparation/issue-324-client-task-sequence-corpus.md @@ -33,10 +33,12 @@ paths. Each captured artifact pins: - the original basename and rotation kind; - the sanitized capture source path; - either the `_SMSTSLogPath` value observed in that physical artifact or an - explicit null for an incomplete fragment that contains no such token; + 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 -- whether that physical fragment is a complete logical CCM record. +- 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 @@ -125,7 +127,8 @@ This requirement is pinned independently for: 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 -smallest bounded next `client-task-sequence-smsts` path class to collect. +`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 @@ -159,11 +162,17 @@ low-confidence, non-correlatable source-local observations. ## Coverage semantics -Capture state and execution state are independent: +Physical capture, logical-record framing, logical coverage, and execution state +are independent: -- `captured` means the physical artifact was available and complete; -- `partial` means only incomplete rotation fragments are available; and -- `absent` means the logical artifact was not captured. +- 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 @@ -205,11 +214,19 @@ 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 sorted rows as -`scenario NUL artifactId NUL relativePath NUL fileSha256 LF`, and hashes the -concatenated rows. 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. +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 From 7c567a4ed6f42c684acf1dbd2320a95d83754b53 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:30:34 -0400 Subject: [PATCH 172/422] test(sccm): cover temporal review regressions --- ...m_client_task_sequence_fixture_contract.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) 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 index 05edb5521..090b93425 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -2426,6 +2426,70 @@ fn complete_winpe_record_may_have_no_smsts_path_observation() { .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(); From 6629463876c0b08286f2f84deacee0130736dbb8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:31:06 -0400 Subject: [PATCH 173/422] fix(sccm): reject ambiguous task sequence timing --- .../sccm_client_task_sequence_fixture_contract.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 index 090b93425..4a4611135 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -258,6 +258,7 @@ fn hex_digest(bytes: &[u8]) -> String { } fn corpus_inventory() -> CorpusInventory { + let mut scenario_count = 0; let mut artifacts = 0; let mut evidence_files = 0; let mut evidence_bytes = 0; @@ -266,6 +267,7 @@ fn corpus_inventory() -> CorpusInventory { 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"] @@ -301,7 +303,7 @@ fn corpus_inventory() -> CorpusInventory { digest_rows.sort(); CorpusInventory { - scenarios: SCENARIOS.len(), + scenarios: scenario_count, artifacts, evidence_files, evidence_bytes, @@ -1385,7 +1387,12 @@ fn validate_contract( }) }) .collect::, _>>()?; - derived_order.sort(); + 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()) @@ -1439,7 +1446,7 @@ fn validate_contract( 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::Secs, true) + .to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true) }); if declared_utc != parsed_utc { return Err(format!( From b027b7f999b7c59555d7ba11fd88ff5d480c79af Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:39:46 -0400 Subject: [PATCH 174/422] test(sccm): expose update corpus review gaps --- .../sccm_client_updates_fixture_contract.rs | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 8ff90c263..00f4406be 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -3052,3 +3052,205 @@ fn software_update_fixture_never_elevates_experimental_low_keys_to_causal_confid .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()); + 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", + )); + } + + 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 shape_mutations: [(&str, fn(&mut Value), &str); 6] = [ + ( + "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", + ), + ]; + 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 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}")), + } + } + + assert!( + missing_rejections.is_empty(), + "semantic validator accepted or panicked on adversarial input:\n{}", + missing_rejections.join("\n") + ); +} From 274f189352967a7e68a7e918db384f3654af5fa8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:46:02 -0400 Subject: [PATCH 175/422] test(sccm): reproduce server intake review gaps --- .../tests/sccm_server_intake.rs | 312 +++++++++++++++++- 1 file changed, 311 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index 86d40e6e8..acd3187df 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -1,5 +1,5 @@ use cmtraceopen_parser::sccm::server::windows::{assess_server_intake, SccmServerArtifactPayload}; -use cmtraceopen_parser::sccm::{SccmCoverageState, SccmRole}; +use cmtraceopen_parser::sccm::{SccmCoverageState, SccmRole, SccmRotation}; use serde_json::Value; use std::path::{Path, PathBuf}; @@ -31,6 +31,47 @@ fn load_bundle(scenario: &str) -> (String, Vec) { (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 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") +} + #[test] fn server_intake_normalizes_role_coverage_and_logical_records() { let (complete_manifest, complete_payloads) = load_bundle("complete-multi-role"); @@ -119,3 +160,272 @@ fn server_intake_normalizes_role_coverage_and_logical_records() { "manifest order must not affect normalized output" ); } + +#[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_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"], 279); + 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["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_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::LoUnderscore), + Some(SccmRotation::Numbered(2)), + Some(SccmRotation::Timestamped("20260729-235700".to_owned())), + 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, + ] + ); +} From 0c4f65116d7135978f70e34de08cd7c79eeb304e Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:48:17 -0400 Subject: [PATCH 176/422] fix(sccm): bind update fixture provenance --- .../same-minute-separate/expected.json | 4 +- .../sccm/client/updates/success/manifest.json | 4 +- .../sccm_client_updates_fixture_contract.rs | 270 ++++++++++++++---- .../issue-323-client-updates-corpus.md | 27 +- 4 files changed, 240 insertions(+), 65 deletions(-) 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 index bf31fcaa1..021a05428 100644 --- 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 @@ -96,7 +96,7 @@ }, "phase": "install", "state": "failed", - "lastSuccessfulPhase": "maintenanceWindow", + "lastSuccessfulPhase": null, "classification": "confirmedFailure", "confidence": "high", "confidenceCeiling": "high", @@ -118,7 +118,7 @@ "subjectId": "updates:update:32300000-0000-0000-0000-000000000016", "class": "confirmedFailure", "phase": "install", - "lastSuccessfulPhase": "maintenanceWindow", + "lastSuccessfulPhase": null, "confidence": "high", "confidenceCeiling": "high", "nextArtifact": null, 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 index 0a6490911..95a862097 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/updates/success/manifest.json @@ -216,7 +216,7 @@ ] }, "role": "client", - "kind": "ccmLog", + "kind": "supplementalLog", "captureState": "skipped", "originalBasename": "ReportingEvents.log", "sanitizedSourcePath": "SYNTHETIC://root-a/WindowsUpdate/ReportingEvents.log", @@ -224,7 +224,7 @@ "rotation": { "kind": "current" }, - "sourceVersion": "5.00.TEST.0000", + "sourceVersion": null, "capturedUtc": "2026-07-30T02:59:59Z", "bytesCopied": 0, "relativePath": null diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 00f4406be..670a972ca 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -402,11 +402,11 @@ fn string_array(values: &Value, field: &str) -> Vec { .collect() } -fn subject<'a>(expected: &'a Value, contract: &ScenarioContract) -> &'a Value { +fn subject<'a>(expected: &'a Value, contract: &ScenarioContract) -> Option<&'a Value> { if contract.observations > 0 { - &expected["sourceLocalObservations"][0] + expected["sourceLocalObservations"].as_array()?.first() } else { - &expected["transactions"][0] + expected["transactions"].as_array()?.first() } } @@ -439,7 +439,16 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> if expected["workflow"] != "updates" || expected["scenario"] != scenario { failures.push(format!("{scenario}: workflow/scenario identity drifted")); } - let state_chain = string_array(expected, "stateChain"); + 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:?}")); } @@ -459,15 +468,20 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> )); } - let transactions = expected["transactions"] - .as_array() - .expect("transactions must be an array"); - let observations = expected["sourceLocalObservations"] - .as_array() - .expect("sourceLocalObservations must be an array"); - let findings = expected["findings"] - .as_array() - .expect("findings must be an array"); + 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 @@ -539,28 +553,32 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> } if contract.transactions + contract.observations > 0 { - let subject = subject(expected, contract); - if optional_json_string(subject, "phase").as_deref() != contract.phase - || json_string(subject, "state") != contract.state - || json_string(subject, "classification") != contract.classification - || json_string(subject, "confidenceCeiling") != 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 - )); + if let Some(subject) = subject(expected, contract) { + if optional_json_string(subject, "phase").as_deref() != contract.phase + || json_string(subject, "state") != contract.state + || json_string(subject, "classification") != contract.classification + || json_string(subject, "confidenceCeiling") != 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 coverage = expected["coverage"] - .as_array() - .expect("coverage must be an array"); + let Some(coverage) = expected["coverage"].as_array() else { + failures.push(format!("{scenario}: coverage must be an array")); + return failures; + }; let coverage_pairs = coverage .iter() .map(|entry| { @@ -992,6 +1010,42 @@ fn record_proves_phase_outcome( (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, } } @@ -1114,15 +1168,15 @@ fn transaction_binding_failures( )), } - let requires_phase_outcome = transaction["confidence"] == "high" - && transaction["confidenceCeiling"] == "high" - && matches!( - ( - transaction["classification"].as_str(), - transaction["state"].as_str() - ), - (Some("success"), Some("succeeded")) | (Some("confirmedFailure"), Some("failed")) - ); + 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 @@ -1133,10 +1187,25 @@ fn transaction_binding_failures( }) { failures.push(format!( - "{scenario}: phase outcome evidence is missing for {transaction_id}" + "{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}" + )); + } + } + let actual_gaps = string_array(transaction, "coverageGapArtifactIds"); if actual_gaps != expected_transaction_gaps(scenario) { failures.push(format!( @@ -1326,6 +1395,37 @@ fn expected_rotation_kind(basename: &str) -> &'static str { } } +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(""); @@ -1333,7 +1433,27 @@ fn manifest_artifact_identity_failures(artifact: &Value) -> Vec { 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 { @@ -1377,22 +1497,31 @@ 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 = if basename == Some("CBS.log") { - "cbsLog" - } else { - "ccmLog" + let expected_kind = match basename { + Some("CBS.log") => "cbsLog", + Some("ReportingEvents.log") => "supplementalLog", + _ => "ccmLog", }; - if group.is_none() + 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 { - vec![format!( + failures.push(format!( "{artifact_id}: artifact kind {:?} is incompatible with group/basename", artifact["kind"] - )] - } else { - Vec::new() + )); } + if artifact["kind"] == "supplementalLog" && !artifact["sourceVersion"].is_null() { + failures.push(format!( + "{artifact_id}: supplementalLog sourceVersion must be null" + )); + } + failures } fn coverage_state_for_artifact(artifact: &Value) -> Option { @@ -1507,7 +1636,21 @@ fn artifact_provenance_projection(manifest: &Value) -> (Value, Vec) { fn profile_binding_failures(manifest: &Value, expected: &Value, scenario: &str) -> Vec { let mut failures = Vec::new(); let profile = &expected["extractionProfile"]; - let validated_families = string_array(profile, "validatedArtifactFamilies"); + 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!( @@ -3148,6 +3291,8 @@ fn software_update_fixture_contract_rejects_phase_provenance_privacy_and_shape_m 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(), @@ -3172,6 +3317,15 @@ fn software_update_fixture_contract_rejects_phase_provenance_privacy_and_shape_m "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", @@ -3189,8 +3343,18 @@ fn software_update_fixture_contract_rejects_phase_provenance_privacy_and_shape_m "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", + )); - let shape_mutations: [(&str, fn(&mut Value), &str); 6] = [ + type ShapeMutation = (&'static str, fn(&mut Value), &'static str); + let shape_mutations: [ShapeMutation; 6] = [ ( "stateChain object", |expected: &mut Value| expected["stateChain"] = serde_json::json!({}), diff --git a/docs/sccm/preparation/issue-323-client-updates-corpus.md b/docs/sccm/preparation/issue-323-client-updates-corpus.md index ab4442daa..0fce31344 100644 --- a/docs/sccm/preparation/issue-323-client-updates-corpus.md +++ b/docs/sccm/preparation/issue-323-client-updates-corpus.md @@ -92,9 +92,13 @@ 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. A High success or -confirmed failure additionally requires a compatible source record containing -the exact key plus the claimed phase disposition/terminal marker. +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 @@ -113,7 +117,9 @@ 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. The `supplemental-conflict` case proves that an +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. @@ -143,7 +149,7 @@ client sources themselves prove it. | `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. | Per transaction | Never time-merge | +| `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`, @@ -182,9 +188,14 @@ 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. 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. +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: From ccb8909b71d74b52c3d037d0d4114d0ff44f684c Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:54:51 -0400 Subject: [PATCH 177/422] fix(sccm): harden server intake evidence contracts --- .../src/sccm/server/windows/intake.rs | 368 +++++++++++++++--- .../tests/sccm_server_intake.rs | 2 +- 2 files changed, 308 insertions(+), 62 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index c44d497a1..bda0d3352 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -60,9 +60,19 @@ pub struct SccmServerArtifactAssessment { pub collected_at_utc: String, pub relative_path: Option, pub bytes_copied: u64, + pub capture_provenance: Option, pub parser_eligible: bool, } +#[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 limit_applied: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[serde(rename_all = "camelCase")] pub enum SccmServerConfiguredPathState { @@ -149,6 +159,7 @@ pub fn assess_server_intake( let mut manifest_artifact_ids = BTreeSet::new(); let mut relative_paths = BTreeSet::new(); + let mut path_fingerprint_lineages = BTreeMap::new(); let mut prepared = Vec::with_capacity(manifest.artifacts.len()); for artifact in manifest.artifacts { if !manifest_artifact_ids.insert(artifact.artifact_id.clone()) { @@ -159,6 +170,7 @@ pub fn assess_server_intake( manifest.synthetic_fixture, &topology.roles_observed, &mut relative_paths, + &mut path_fingerprint_lineages, &payload_by_id, )?; prepared.push(normalized); @@ -179,6 +191,13 @@ pub fn assess_server_intake( 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; @@ -204,7 +223,9 @@ pub fn assess_server_intake( artifact_ids: vec![artifact.artifact_id.clone()], }); - if let Some(request) = request_for_gap(&artifact) { + 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(), @@ -286,13 +307,13 @@ 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" - || !manifest.topology.capture_host.starts_with("LAB-") - || !manifest - .topology - .capture_host - .bytes() - .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'-') + || !matches!( + manifest.topology.capture_host.as_str(), + "LAB-CM01" | "LAB-MP01" + ) { return Err(SccmServerIntakeError::InvalidTopology); } @@ -344,8 +365,11 @@ fn normalize_artifact( synthetic_fixture: bool, roles_observed: &[SccmRole], relative_paths: &mut BTreeSet, + path_fingerprint_lineages: &mut BTreeMap<(String, String, String, String), String>, payload_by_id: &BTreeMap<&str, &[u8]>, ) -> Result { + 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) || !safe_lineage_id(&artifact.rotation.lineage_id, synthetic_fixture) @@ -411,7 +435,12 @@ fn normalize_artifact( { return Err(SccmServerIntakeError::InvalidArtifact); } - (family, Some(classified.basename), declared_rotation, true) + ( + family, + Some(artifact.original_basename.clone()), + declared_rotation, + true, + ) } else { (family, None, None, false) } @@ -426,6 +455,30 @@ fn normalize_artifact( return Err(SccmServerIntakeError::InvalidArtifact); }; + let path_fingerprint_key = ( + role_sort_key(&artifact.producer_role).to_owned(), + artifact.source_id.clone(), + workflow_subject_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default() + .to_owned(), + 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 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() { @@ -437,16 +490,18 @@ fn normalize_artifact( let relative_path = validate_relative_path( artifact.relative_path.clone(), original_basename.as_deref(), - &artifact.capture_state, + &artifact, + rotation.as_ref(), relative_paths, )?; - let bytes = validate_payload_contract(&artifact, relative_path.as_deref(), payload_by_id)?; - let profile_eligible = artifact - .source_version + let (bytes, capture_provenance) = + validate_payload_contract(&artifact, relative_path.as_deref(), payload_by_id)?; + 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)?; if artifact.encoding.as_deref() != Some("utf-8") { @@ -463,7 +518,7 @@ fn normalize_artifact( original_path: None, host: artifact.producer_host_handle.clone(), role: artifact.producer_role.clone(), - configmgr_version: artifact.source_version.clone(), + configmgr_version: source_version.clone(), collected_at_utc: Some(collected_at_utc.clone()), rotation: rotation .clone() @@ -473,6 +528,9 @@ fn normalize_artifact( }, content, ); + if evidence.is_empty() { + state = SccmCoverageState::ParseFailed; + } } Ok(PreparedArtifact { @@ -493,15 +551,16 @@ fn normalize_artifact( original_basename, rotation, rotation_lineage_handle: artifact.rotation.lineage_id, - state: artifact.capture_state, + state, configured_path_state, configured_path_class, path_fingerprint: artifact.configured_path_provenance.path_fingerprint, - source_version: artifact.source_version, + source_version, profile_eligible, collected_at_utc, relative_path, bytes_copied: artifact.bytes_copied, + capture_provenance, parser_eligible, }, evidence, @@ -512,13 +571,9 @@ fn validate_payload_contract<'a>( artifact: &RawServerArtifact, relative_path: Option<&str>, payload_by_id: &'a BTreeMap<&str, &'a [u8]>, -) -> Result, SccmServerIntakeError> { +) -> Result<(Option<&'a [u8]>, Option), SccmServerIntakeError> { let payload = payload_by_id.get(artifact.artifact_id.as_str()).copied(); - let physical = matches!( - artifact.capture_state, - SccmCoverageState::Captured | SccmCoverageState::Capped - ); - if physical { + if is_physical_state(&artifact.capture_state) { let payload = payload.ok_or(SccmServerIntakeError::MissingPayload)?; if relative_path.is_none() { return Err(SccmServerIntakeError::InvalidArtifact); @@ -539,12 +594,32 @@ fn validate_payload_contract<'a>( && 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, }; - if !valid_limit || artifact.encoding.is_none() { + 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)); + return Ok(( + Some(payload), + Some(SccmServerCaptureProvenance { + schema_version: 1, + encoding: encoding.to_owned(), + byte_limit: limit.byte_limit, + limit_applied: limit.limit_applied, + }), + )); } if payload.is_some() @@ -555,28 +630,72 @@ fn validate_payload_contract<'a>( { return Err(SccmServerIntakeError::UnexpectedPayload); } - Ok(None) + Ok((None, None)) } fn validate_relative_path( relative_path: Option, original_basename: Option<&str>, - state: &SccmCoverageState, + artifact: &RawServerArtifact, + rotation: Option<&SccmRotation>, relative_paths: &mut BTreeSet, ) -> Result, SccmServerIntakeError> { - let physical = matches!( - state, - SccmCoverageState::Captured | SccmCoverageState::Capped - ); - if !physical { + if !is_physical_state(&artifact.capture_state) { if relative_path.is_some() { return Err(SccmServerIntakeError::InvalidArtifact); } return Ok(None); } let relative_path = relative_path.ok_or(SccmServerIntakeError::InvalidArtifact)?; - if !relative_path.starts_with("evidence/sccm/server/") - || relative_path.starts_with('/') + let components = relative_path.split('/').collect::>(); + let expected_role = + role_path_segment(&artifact.producer_role).ok_or(SccmServerIntakeError::InvalidArtifact)?; + let expected_rotation = + rotation_path_segment(rotation).ok_or(SccmServerIntakeError::InvalidArtifact)?; + let basename = original_basename.ok_or(SccmServerIntakeError::InvalidArtifact)?; + let mut cursor = 0; + let fixed_prefix = [ + "evidence", + "sccm", + "server", + expected_role, + artifact.source_id.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(basename) + || components.len() != cursor + 2 || relative_path.contains('\\') || relative_path.split('/').any(|segment| { segment.is_empty() @@ -586,14 +705,56 @@ fn validate_relative_path( .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) }) - || original_basename.is_none_or(|basename| !relative_path.ends_with(basename)) - || !relative_paths.insert(relative_path.clone()) + || !relative_paths.insert(relative_path.to_ascii_lowercase()) { return Err(SccmServerIntakeError::InvalidArtifact); } Ok(Some(relative_path)) } +fn is_physical_state(state: &SccmCoverageState) -> bool { + matches!( + state, + SccmCoverageState::Captured | SccmCoverageState::Capped | SccmCoverageState::ParseFailed + ) +} + +fn safe_encoding(encoding: &str) -> bool { + matches!(encoding, "utf-8" | "utf-16le" | "windows-1252" | "unknown") +} + +fn role_path_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_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> { @@ -643,7 +804,29 @@ fn normalize_collected_utc(value: &str) -> Result .to_rfc3339_opts(SecondsFormat::Secs, true)) } -fn request_for_gap(artifact: &SccmServerArtifactAssessment) -> Option { +fn logical_source_key(artifact: &SccmServerArtifactAssessment) -> (String, String, String) { + ( + role_sort_key(&artifact.producer_role).to_owned(), + artifact.source_id.clone(), + artifact + .workflow_subject_role + .as_ref() + .map(role_sort_key) + .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; + } let reason = match artifact.state { SccmCoverageState::Absent => "source was absent; role outcome remains unknown", SccmCoverageState::AccessDenied => "source access was denied; role outcome remains unknown", @@ -663,6 +846,25 @@ fn request_for_gap(artifact: &SccmServerArtifactAssessment) -> Option, + synthetic_fixture: bool, +) -> Result, SccmServerIntakeError> { + let Some(value) = value else { + return Ok(None); + }; + let safe = if synthetic_fixture { + value == "5.00.TEST" + } 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 source_version_is_profile_eligible(value: &str, synthetic_fixture: bool) -> bool { if synthetic_fixture && value == "5.00.TEST" { return true; @@ -686,19 +888,34 @@ fn source_version_is_profile_eligible(value: &str, synthetic_fixture: bool) -> b fn safe_manifest_artifact_id(value: &str, synthetic_fixture: bool) -> bool { if synthetic_fixture { - return safe_synthetic_identifier(value); + // 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" + | "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" + | "sitecomp-current" + | "sup-sync-capped" + | "sup-sync-current" + | "unknown-db-export" + | "z-site-status" + ); } opaque_sha256_handle(value, "cmtraceopen.artifact.sha256.v1:") } -fn safe_synthetic_identifier(value: &str) -> bool { - !value.is_empty() - && value.len() <= 128 - && value.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') - }) -} - fn safe_source_id(value: &str) -> bool { matches!( value, @@ -715,20 +932,48 @@ fn safe_source_id(value: &str) -> bool { fn safe_lineage_id(value: &str, synthetic_fixture: bool) -> bool { if synthetic_fixture { - return safe_synthetic_identifier(value); + return matches!( + value, + "dp-dist-lab" + | "dp-distribution-default" + | "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" + | "unknown-db-export" + ); } opaque_sha256_handle(value, "cmtraceopen.lineage.sha256.v1:") } fn safe_path_fingerprint(value: &str, synthetic_fixture: bool) -> bool { if synthetic_fixture { - return value.strip_prefix("synthetic:path:").is_some_and(|suffix| { - !suffix.is_empty() - && suffix.len() <= 96 - && suffix - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - }); + return matches!( + value, + "synthetic:path:a-mp" + | "synthetic:path:a-site" + | "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:unsupported-db" + | "synthetic:path:z-site" + ); } opaque_sha256_handle(value, "cmtraceopen.path.sha256.v1:") } @@ -749,15 +994,16 @@ fn safe_optional_handle(value: Option<&str>, synthetic_fixture: bool, domain: &s return true; }; if synthetic_fixture { - return value - .strip_prefix(&format!("synthetic:{domain}:")) - .is_some_and(|suffix| { - !suffix.is_empty() - && suffix.len() <= 96 - && suffix.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' - }) - }); + return match domain { + "host" => matches!(value, "synthetic:host:mp-01" | "synthetic:host:site-01"), + "subject" => { + matches!( + value, + "synthetic:subject:dp-01" | "synthetic:subject:sup-01" + ) + } + _ => false, + }; } opaque_sha256_handle(value, &format!("cmtraceopen.{domain}.sha256.v1:")) } diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index acd3187df..9f9d20538 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -259,7 +259,7 @@ fn server_intake_preserves_physical_parse_failure_provenance() { let serialized = serde_json::to_value(&assessment).expect("assessment serializes"); let artifact = artifact_json(&serialized, "mp-policy-multiline"); - assert_eq!(artifact["bytesCopied"], 279); + assert_eq!(artifact["bytesCopied"], 207); assert_eq!(artifact["captureProvenance"]["schemaVersion"], 1); assert_eq!(artifact["captureProvenance"]["encoding"], "utf-8"); assert_eq!(artifact["captureProvenance"]["byteLimit"], 4096); From 79c57d7d64b75b7547906a47847e725a05b22bec Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:59:13 -0400 Subject: [PATCH 178/422] test(sccm): expose scenario shape panics --- .../sccm_client_updates_fixture_contract.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 670a972ca..3b6407f72 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -3400,6 +3400,33 @@ fn software_update_fixture_contract_rejects_phase_provenance_privacy_and_shape_m )); } + 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 missing_rejections = Vec::new(); for (label, scenario, manifest, expected, marker) in mutations { match validate_without_panicking(&scenario, &manifest, &expected) { From b15235b851d0fd6da7bf82e3de52988523d7c56a Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 11:59:58 -0400 Subject: [PATCH 179/422] fix(sccm): reject missing scenario subjects --- .../sccm_client_updates_fixture_contract.rs | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 3b6407f72..ad2a23bb5 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -675,15 +675,20 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> "{scenario}: selected synthetic profile identity drifted" )); } - if scenario == "invalid-offset" - && (transactions[0]["ordering"]["crossArtifactComparable"] != false - || transactions[0]["ordering"]["highConfidenceEligible"] != false - || transactions[0]["ordering"]["reason"] != "invalidOffset") - { - failures.push( - "invalid-offset: invalid provenance must disable cross-artifact high confidence" - .to_owned(), - ); + 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 update_ids = transactions @@ -696,14 +701,21 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> ); } } - if scenario == "supplemental-conflict" - && (transactions[0]["state"] != "succeeded" - || observations[0]["keyConfidence"] != "none" - || observations[0]["confidenceCeiling"] != "low") - { - failures.push( - "supplemental-conflict: unkeyed CBS evidence cannot override client success".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 From a1d9093cc00df0741b201c5e2cd062808467f465 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 12:16:58 -0400 Subject: [PATCH 180/422] test(sccm): expose server intake identity gaps --- .../tests/sccm_server_intake.rs | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index 9f9d20538..c3e319c4c 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -1,6 +1,6 @@ use cmtraceopen_parser::sccm::server::windows::{assess_server_intake, SccmServerArtifactPayload}; use cmtraceopen_parser::sccm::{SccmCoverageState, SccmRole, SccmRotation}; -use serde_json::Value; +use serde_json::{json, Value}; use std::path::{Path, PathBuf}; fn intake_root() -> PathBuf { @@ -241,6 +241,77 @@ fn server_intake_reserves_windows_equivalent_paths_and_fingerprints() { ); } +#[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!( + assess_server_intake(&serialize_manifest(&manifest), &payloads).is_err(), + "caller-chosen artifact and root labels must not duplicate one canonical identity" + ); +} + #[test] fn server_intake_preserves_physical_parse_failure_provenance() { let (manifest_json, payloads) = load_bundle("multiline"); From 46f5a48f41fdaa2166ea041548b0007cd148f183 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 12:18:21 -0400 Subject: [PATCH 181/422] fix(sccm): enforce server artifact identity contracts --- crates/cmtraceopen-parser/src/sccm/catalog.rs | 2 +- .../src/sccm/server/windows/catalog.rs | 10 +++++- .../src/sccm/server/windows/intake.rs | 31 ++++++++++++++++++- .../tests/sccm_spine_contract.rs | 2 +- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs index f453b2035..370a4fee5 100644 --- a/crates/cmtraceopen-parser/src/sccm/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -309,7 +309,7 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ CatalogSpec { basename: "mpcontrol", logical_name: "mpcontrol", - role: SccmRole::ManagementPoint, + role: SccmRole::SiteServer, family: SccmArtifactFamily::ManagementPoint, }, CatalogSpec { diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs index 47252c72a..4ce68372c 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs @@ -46,7 +46,15 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ source_id: "server-mp-policy", producer_role: SccmRole::ManagementPoint, workflow_subject_role: None, - logical_names: &["mpGetPolicy", "mpLocation", "mpcontrol"], + logical_names: &["mpGetPolicy", "mpLocation"], + 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"], source_kind: SccmServerSourceKind::CcmLog, supplemental: false, }, diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index bda0d3352..f5e4abdd6 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -12,6 +12,9 @@ use crate::sccm::{ use super::catalog::{classify_declared_server_source, expected_family, SccmServerSourceKind}; +type PathFingerprintKey = (String, String, String, String); +type CanonicalArtifactIdentity = (String, String, String, String, String, String, String); + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SccmServerArtifactPayload { pub manifest_artifact_id: String, @@ -160,6 +163,7 @@ pub fn assess_server_intake( 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()) { @@ -171,6 +175,7 @@ pub fn assess_server_intake( &topology.roles_observed, &mut relative_paths, &mut path_fingerprint_lineages, + &mut canonical_artifact_identities, &payload_by_id, )?; prepared.push(normalized); @@ -365,7 +370,8 @@ fn normalize_artifact( synthetic_fixture: bool, roles_observed: &[SccmRole], relative_paths: &mut BTreeSet, - path_fingerprint_lineages: &mut BTreeMap<(String, String, String, String), String>, + path_fingerprint_lineages: &mut BTreeMap, + canonical_artifact_identities: &mut BTreeSet, payload_by_id: &BTreeMap<&str, &[u8]>, ) -> Result { let source_version = @@ -479,6 +485,29 @@ fn normalize_artifact( } } + let canonical_identity = ( + role_sort_key(&artifact.producer_role).to_owned(), + artifact.source_id.clone(), + workflow_subject_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default() + .to_owned(), + 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() { diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 0054e8fbe..744e37215 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -5715,7 +5715,7 @@ fn expected_catalog_tuples() -> Vec { ), ( "mpcontrol.log", - SccmRole::ManagementPoint, + SccmRole::SiteServer, "mpcontrol", SccmArtifactFamily::ManagementPoint, true, From 6150687b8e4b05e0b88796f0839e64637701aad8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:06:01 -0400 Subject: [PATCH 182/422] test(sccm): probe overlapping evidence citation ranges Before this test the management contract only rejected unsorted or exactly duplicated evidence tuples, so sorted overlapping ownership ranges 1..1 plus 1..2 and transaction ranges 1..2 plus 2..3 were accepted while double-counting the same logical CCM record. The new mutation probes fail today and pin the review requirement that cited ranges resolve to unique (artifactId, line) identities. Refs #326 --- ...sccm_client_management_fixture_contract.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 1546d46fb..ec379db9b 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -2983,3 +2983,41 @@ fn profile_citation_and_public_observation_mutations_fail_closed() { "profile, citation, or public observation mutations were accepted: {accepted:?}" ); } + +#[test] +fn overlapping_evidence_citation_mutations_fail_closed() { + let mut accepted = Vec::new(); + + let (mixed_root, mixed_manifest, mut mixed_expected) = load_contract("mixed-unrelated"); + mixed_expected["ownership"]["evidence"] = serde_json::json!([ + { "artifactId": "mixed-owner-unknown", "startLine": 1, "endLine": 1 }, + { "artifactId": "mixed-owner-unknown", "startLine": 1, "endLine": 2 } + ]); + if mutation_was_accepted( + "mixed-unrelated", + &mixed_root, + &mixed_manifest, + &mixed_expected, + ) { + accepted.push("sorted overlapping ownership ranges double-count logical record 1"); + } + + let (script_root, script_manifest, mut script_expected) = load_contract("script-success"); + script_expected["transactions"][0]["evidence"] = serde_json::json!([ + { "artifactId": "script-success-current", "startLine": 1, "endLine": 2 }, + { "artifactId": "script-success-current", "startLine": 2, "endLine": 3 } + ]); + if mutation_was_accepted( + "script-success", + &script_root, + &script_manifest, + &script_expected, + ) { + accepted.push("sorted overlapping transaction ranges double-count the Execute record"); + } + + assert!( + accepted.is_empty(), + "overlapping evidence citation mutations were accepted: {accepted:?}" + ); +} From 78e0f346ad99cae5a433d137bededcced0fca4bc Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:06:29 -0400 Subject: [PATCH 183/422] fix(sccm): reject overlapping evidence citation ranges Before this change evidence uniqueness was enforced only on the reference tuple, so sorted overlapping ranges could cite the same logical CCM record twice and inflate chronology or corroboration. Expand every cited range into (artifactId, line) identities with a shared helper and fail closed when ownership or transaction citations resolve to a duplicate record, before any classification is derived. Refs #326 --- .../sccm_client_management_fixture_contract.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index ec379db9b..3ed71e58d 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -396,6 +396,13 @@ fn public_observation_claim_is_safe(value: &str) -> bool { }) } +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() @@ -1295,6 +1302,9 @@ fn validate_contract( { 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() @@ -1522,6 +1532,11 @@ fn validate_contract( "{transaction_id} evidence references are duplicated or not sorted" )); } + if !evidence_refs_cite_unique_records(&transaction_ref_order) { + return Err(format!( + "{transaction_id} evidence ranges overlap and double-count a logical record" + )); + } let records = evidence_records(scenario_root, &artifacts_by_id, &transaction["evidence"])?; if records.is_empty() { return Err(format!("{transaction_id} has no cited evidence")); From 63b87587cbce9497f3eb7df08a64a3d7093f9d4b Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:06:53 -0400 Subject: [PATCH 184/422] test(sccm): probe alternate-drive and identifier privacy leaks Before this test the public privacy predicate was field- and spelling-specific: it rejected only c:/, /users/, /home/, backslash, and at-sign spellings inside observation claims. Alternate drive paths such as D:/Profiles/RealUser and UNC shares such as //LAB-CLIENT-01/share/RealUser passed the claim check, and neither observationId nor nextArtifact.reason was privacy-checked at all. The four new mutation probes fail today and pin the review requirement that one fail-closed projection covers every public string surface. Refs #326 --- ...sccm_client_management_fixture_contract.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 3ed71e58d..556362f39 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -3036,3 +3036,67 @@ fn overlapping_evidence_citation_mutations_fail_closed() { "overlapping evidence citation mutations were accepted: {accepted:?}" ); } + +#[test] +fn public_surface_privacy_mutations_fail_closed() { + let mut accepted = Vec::new(); + + let (observed_root, observed_manifest, observed_expected) = + load_contract("software-center-observed"); + + let mut alternate_drive_claim = observed_expected.clone(); + alternate_drive_claim["sourceLocalObservations"][0]["claim"] = + Value::String("Observed under D:/Profiles/RealUser remains source local.".to_owned()); + if mutation_was_accepted( + "software-center-observed", + &observed_root, + &observed_manifest, + &alternate_drive_claim, + ) { + accepted.push("alternate drive-letter path in a public observation claim"); + } + + let mut unc_claim = observed_expected.clone(); + unc_claim["sourceLocalObservations"][0]["claim"] = Value::String( + "Observed under //LAB-CLIENT-01/share/RealUser remains source local.".to_owned(), + ); + if mutation_was_accepted( + "software-center-observed", + &observed_root, + &observed_manifest, + &unc_claim, + ) { + accepted.push("UNC share path in a public observation claim"); + } + + let mut identity_observation_id = observed_expected.clone(); + identity_observation_id["sourceLocalObservations"][0]["observationId"] = + Value::String("software-center-observed-D:/Users/RealUser".to_owned()); + if mutation_was_accepted( + "software-center-observed", + &observed_root, + &observed_manifest, + &identity_observation_id, + ) { + accepted.push("identity-bearing public observation id"); + } + + let (deferred_root, deferred_manifest, mut deferred_expected) = + load_contract("notification-deferred"); + deferred_expected["transactions"][0]["nextArtifact"]["reason"] = Value::String( + "Collect D:/Profiles/RealUser for the same exact notification key.".to_owned(), + ); + if mutation_was_accepted( + "notification-deferred", + &deferred_root, + &deferred_manifest, + &deferred_expected, + ) { + accepted.push("alternate drive-letter path in a next-artifact request reason"); + } + + assert!( + accepted.is_empty(), + "public surface privacy mutations were accepted: {accepted:?}" + ); +} From da31208060b715d29a386da341aec19e6f2d8e6e Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:08:20 -0400 Subject: [PATCH 185/422] fix(sccm): fail closed on every public string surface Before this change the claim privacy predicate blocklisted specific spellings (c:/, /users/, /home/, backslash, at-sign), so alternate drive letters and forward-slash UNC shares passed, and observation ids, transaction ids, and next-artifact reasons had no privacy projection. Replace the blocklist with one closed narrative grammar (bounded ASCII narrative characters, SID and dotted-host rejection) applied to claims and next-artifact reasons, and a closed lowercase identifier grammar applied to observation and transaction ids. Refs #326 --- ...sccm_client_management_fixture_contract.rs | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 556362f39..c37a36b85 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -352,21 +352,34 @@ fn source_version_matches_selected_profile(value: &str) -> bool { .is_some_and(|suffix| suffix.len() == 4 && suffix.bytes().all(|byte| byte.is_ascii_digit())) } -fn public_observation_claim_is_safe(value: &str) -> bool { - if value.len() > 512 +fn public_identifier_is_safe(value: &str) -> bool { + !value.is_empty() + && value.len() <= 96 + && 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) - || value.contains(['\\', '@']) - || value.contains("://") { return false; } - let lower = value.to_ascii_lowercase(); - if lower.contains("/users/") - || lower.contains("/home/") - || lower.contains("s-1-5-") - || lower.contains("c:/") - { + if !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b' ' | b'.' | b',' | b';' | b'\'' | b'-' | b'(' | b')') + }) { + return false; + } + + if value.to_ascii_lowercase().contains("s-1-5-") { return false; } @@ -1415,6 +1428,11 @@ fn validate_contract( 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, &[ @@ -1683,6 +1701,11 @@ fn validate_contract( 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 @@ -1722,6 +1745,11 @@ fn validate_contract( 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, &[ @@ -1800,7 +1828,7 @@ fn validate_contract( "{observation_id} makes an unsupported causal claim" )); } - if !public_observation_claim_is_safe(claim) { + if !public_free_text_is_safe(claim) { return Err(format!( "{observation_id} contains unsafe public identity or path data" )); From 593c7442463207287e3a89c0509367b4e119544d Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:08:42 -0400 Subject: [PATCH 186/422] test(sccm): probe duplicate ownership coverage gap entries Before this test ownership.coverageGapArtifactIds was checked for sort order only, so an adjacent duplicate of the same gap artifact id was accepted in both scenarios that declare an ownership coverage gap. The new mutation probes fail today and pin the review requirement that public deterministic arrays reject duplicate semantic entries. Refs #326 --- ...sccm_client_management_fixture_contract.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index c37a36b85..ccf2cf2c0 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -3128,3 +3128,30 @@ fn public_surface_privacy_mutations_fail_closed() { "public surface privacy mutations were accepted: {accepted:?}" ); } + +#[test] +fn duplicate_coverage_gap_ownership_mutations_fail_closed() { + let mut accepted = Vec::new(); + + for scenario in ["co-management-unknown", "software-center-insufficient"] { + let (scenario_root, manifest, mut expected) = load_contract(scenario); + let gaps = expected["ownership"]["coverageGapArtifactIds"] + .as_array_mut() + .expect("ownership coverage gaps are an array"); + let duplicate = gaps + .first() + .expect("scenario declares an ownership coverage gap") + .clone(); + gaps.insert(0, duplicate); + if mutation_was_accepted(scenario, &scenario_root, &manifest, &expected) { + accepted.push(format!( + "{scenario} adjacent duplicate ownership coverage gap" + )); + } + } + + assert!( + accepted.is_empty(), + "duplicate coverage gap mutations were accepted: {accepted:?}" + ); +} From fd838092db46af3714e124a7733c0f9ae3da8ffc Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:09:07 -0400 Subject: [PATCH 187/422] fix(sccm): reject duplicate coverage gap artifact ids Before this change ownership and transaction coverageGapArtifactIds arrays only verified sort order, so adjacent duplicates of the same gap artifact id passed and let one gap claim count twice. Deduplicate the sorted projection before comparing so any repeated semantic entry in these public deterministic arrays fails closed, matching the observation artifactIds idiom. Refs #326 --- .../tests/sccm_client_management_fixture_contract.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index ccf2cf2c0..a4972fa35 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -1389,8 +1389,9 @@ fn validate_contract( )?; 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 not sorted".to_owned()); + return Err("ownership coverage gaps are duplicated or not sorted".to_owned()); } for artifact_id in ownership_gap_ids { let artifact = artifacts_by_id @@ -1672,8 +1673,11 @@ fn validate_contract( )?; 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 not sorted")); + return Err(format!( + "{transaction_id} coverage gaps are duplicated or not sorted" + )); } for artifact_id in coverage_gap_ids { let artifact = artifacts_by_id From 34cf547ca7c5bdd9a45ed0b5c6211d93f9278fd0 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:09:48 -0400 Subject: [PATCH 188/422] style(sccm): apply rustfmt to new contract checks Before this change the new overlap helper and ownership overlap rejection did not match rustfmt output for the changed file. Reformat only the lane-authored test so the scoped rustfmt gate stays clean. Refs #326 --- .../tests/sccm_client_management_fixture_contract.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index a4972fa35..1bed4b211 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -411,9 +411,11 @@ fn public_free_text_is_safe(value: &str) -> bool { 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))) - }) + 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> { @@ -1316,7 +1318,9 @@ fn validate_contract( 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()); + 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")?; From 716cd6d5f9db4517b8eaa4fee40820df0e29e566 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:06:41 -0400 Subject: [PATCH 189/422] test(sccm): pin exact-token keys and one-run findings Before this change validate_contract admitted an exact key by raw substring search, so a strict prefix of the recorded executionId token (or a suffixed on-disk token) still satisfied the declared key, and every insufficientEvidence finding was treated as transaction-bound unconditionally, so one finding could pool evidence from two unrelated same-time executions. Add two failing mutation tests: one proves a prefix key and a suffixed recorded token are both accepted, the other appends run B evidence to the run A finding in unrelated-runs and proves the validator returns Ok. Both must fail closed. Refs #324 --- ...m_client_task_sequence_fixture_contract.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) 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 index 4a4611135..d6f8cfd4f 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -2840,3 +2840,57 @@ fn rotation_path_provenance_cannot_be_borrowed_from_a_shared_fingerprint() { 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 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}"); +} From 7e50ece914dcce217953179c1fd18522b37bc0e0 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:07:53 -0400 Subject: [PATCH 190/422] fix(sccm): admit keys by token and bind findings to one run Before this change the exact-key check used str::contains, so a declared key field matched any record where field=value appeared as a substring, including a strict prefix of a longer on-disk token. Findings classified insufficientEvidence bypassed the transaction-evidence binding entirely, so one finding could cite records from two unrelated same-time executions. Split each cited record on whitespace and CCM delimiters and require every declared field=value pair to appear as a complete token. Rework the finding binding: a transaction-scoped finding must bind to exactly one transaction of the same classification that contains every cited reference (terminal classes must also cite that transaction's terminal evidence), and an unkeyed insufficientEvidence finding may only cite source-local observation evidence or stand on coverage gaps alone. Refs #324 --- ...m_client_task_sequence_fixture_contract.rs | 56 ++++++++++++++----- 1 file changed, 41 insertions(+), 15 deletions(-) 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 index d6f8cfd4f..bd31a0594 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -397,6 +397,15 @@ fn smsts_log_paths(contents: &str) -> BTreeSet { .collect() } +fn complete_field_tokens(record_text: &str) -> BTreeSet<&str> { + record_text + .split(|character: char| { + character.is_whitespace() || matches!(character, '[' | ']' | '<' | '>') + }) + .filter(|token| !token.is_empty()) + .collect() +} + fn path_class_for_sanitized_path(path: &str) -> Option<&'static str> { [ ("SYNTHETIC://client/", "client"), @@ -1233,12 +1242,13 @@ fn validate_contract( } let record_text = evidence_text(scenario_root, &artifacts_by_id, evidence_ref)?; + let record_tokens = complete_field_tokens(&record_text); if let Some(missing_needle) = key_needles .iter() - .find(|needle| !record_text.contains(needle.as_str())) + .find(|needle| !record_tokens.contains(needle.as_str())) { return Err(format!( - "{transaction_id}: declared key fields do not co-occur in cited complete CCM record ({missing_needle})" + "{transaction_id}: declared key fields do not co-occur as complete tokens in cited complete CCM record ({missing_needle})" )); } cited_record_texts.push(record_text); @@ -1670,32 +1680,48 @@ fn validate_contract( let classification = finding["classification"] .as_str() .ok_or_else(|| format!("{finding_id}: classification is not a string"))?; - let outcome_is_transaction_bound = match classification { - "success" | "confirmedFailure" => transactions.iter().any(|transaction| { + let binding_transactions = transactions + .iter() + .filter(|transaction| { transaction["classification"] == classification - && !transaction["terminalEvidence"].is_null() - && evidence - .iter() - .any(|evidence_ref| evidence_ref == &transaction["terminalEvidence"]) - }), - "blockedOrDeferred" => transactions.iter().any(|transaction| { - transaction["classification"] == "blockedOrDeferred" + && !evidence.is_empty() && transaction["evidence"] .as_array() .is_some_and(|transaction_evidence| { - evidence.iter().any(|evidence_ref| { + evidence.iter().all(|evidence_ref| { transaction_evidence .iter() .any(|transaction_ref| transaction_ref == evidence_ref) }) }) - }), - "insufficientEvidence" => true, + }) + .collect::>(); + let cited_refs_are_source_local = !evidence.is_empty() + && evidence.iter().all(|evidence_ref| { + observations + .iter() + .any(|observation| &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| 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 terminal/keyed transaction evidence" + "{finding_id}: finding outcome is not bound to exactly one keyed transaction or its source-local citations" )); } if finding["serverCauseClaimed"] != false From d7da8f9ab4ff53ef4cab47b42f2fee99d17bbb5d Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:08:21 -0400 Subject: [PATCH 191/422] test(sccm): pin boundary binding and absent rotation Before this change validate_contract never compared the declared correlationBoundary scope, joinFields, or forbiddenJoinFields against the constants it enforces, so a fixture could declare a cross-side scope, a timestamp join, or an empty forbidden list and still validate, and every checked-in fixture omitted the forbidden component field unnoticed. It also accepted an absent artifact that declares rotation.fragmentComplete even though no physical fragment exists to be incomplete. Add two failing mutation tests: one drives scope, joinFields, and forbiddenJoinFields drift on the completed scenario, the other injects fragmentComplete on the absent incomplete artifact. Both must fail closed. Refs #324 --- ...m_client_task_sequence_fixture_contract.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) 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 index bd31a0594..bb193b0f9 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -2904,6 +2904,51 @@ fn exact_key_admission_requires_complete_value_tokens() { 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"; From 1db3f4cd3bbdd26ba9b20956243dc63e0810dc67 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:09:26 -0400 Subject: [PATCH 192/422] fix(sccm): bind correlation boundary and absent rotation Before this change the declared correlationBoundary was never compared with what validate_contract enforces: every fixture omitted the forbidden component join field, and scope or joinFields could drift to cross-side values without failing. The absent artifact in the incomplete scenario also declared rotation.fragmentComplete even though no physical fragment exists. Name the enforced exact-key and forbidden-join constants once, require the declared forbidden list to equal the enforced list, require keyed scenarios to declare an enforced client-side scope with exactly the exact-key join fields, and require unkeyed scenarios to stay sourceLocalOnly or coverageOnly with no join fields. Reject rotation.fragmentComplete on noncapture artifacts, add component to all seventeen declared forbidden lists, and drop the phantom flag from the absent artifact. Refs #324 --- .../client-install-failure/expected.json | 2 +- .../client-installed/expected.json | 2 +- .../complete-looking-unkeyed/expected.json | 2 +- .../task_sequence/completed/expected.json | 2 +- .../disk-image-failure/expected.json | 2 +- .../task_sequence/incomplete/expected.json | 2 +- .../task_sequence/incomplete/manifest.json | 2 +- .../invalid-offset/expected.json | 2 +- .../task_sequence/post-format/expected.json | 2 +- .../task_sequence/pre-client/expected.json | 2 +- .../reboot-continuation/expected.json | 2 +- .../relocated-fragments/expected.json | 2 +- .../rotation-boundary/expected.json | 2 +- .../software-install-failure/expected.json | 2 +- .../terminal-preflight/expected.json | 2 +- .../unknown-profile/expected.json | 2 +- .../unrelated-runs/expected.json | 2 +- .../client/task_sequence/winpe/expected.json | 2 +- ...m_client_task_sequence_fixture_contract.rs | 71 +++++++++++++++++-- 19 files changed, 82 insertions(+), 25 deletions(-) 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 index 0749e0743..a2d636ade 100644 --- 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 @@ -33,5 +33,5 @@ "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"]} + "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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/client-installed/expected.json index 2c7fd2854..95f868b01 100644 --- 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 @@ -33,5 +33,5 @@ "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"]} + "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/complete-looking-unkeyed/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/complete-looking-unkeyed/expected.json index 079125817..d92cb18ea 100644 --- 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 @@ -17,5 +17,5 @@ "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"]} + "correlationBoundary": {"scope":"sourceLocalOnly","joinFields":[],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} } 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 index f2e7f8e84..777345285 100644 --- 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 @@ -31,5 +31,5 @@ ], "sourceLocalObservations": [], "findings": [], - "correlationBoundary": {"scope":"clientOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName"]} + "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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/disk-image-failure/expected.json index 5d3a68822..87727b5b0 100644 --- 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 @@ -33,5 +33,5 @@ "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"]} + "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/incomplete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/incomplete/expected.json index 36a346361..e321b8336 100644 --- 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 @@ -13,5 +13,5 @@ "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"]} + "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 index d5de55c2b..2a88fd776 100644 --- 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 @@ -5,6 +5,6 @@ "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","fragmentComplete":false},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:35:00Z","bytesCopied":0,"relativePath":null} + {"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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/invalid-offset/expected.json index 8a4354df3..0e595cc12 100644 --- 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 @@ -33,5 +33,5 @@ "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"]} + "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/post-format/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/post-format/expected.json index 3edd583ed..467ddebab 100644 --- 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 @@ -33,5 +33,5 @@ "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"]} + "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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/pre-client/expected.json index 7e7a9960d..576012ca7 100644 --- 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 @@ -33,5 +33,5 @@ "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"]} + "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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/reboot-continuation/expected.json index cb1058925..038d7782d 100644 --- 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 @@ -33,5 +33,5 @@ "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"]} + "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/relocated-fragments/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/relocated-fragments/expected.json index 002f8a542..cadb51cdc 100644 --- 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 @@ -44,5 +44,5 @@ ], "sourceLocalObservations": [], "findings": [], - "correlationBoundary": {"scope":"clientRelocationOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName"],"pathOrderSource":"_SMSTSLogPath plus explicit relocationOrdinal"} + "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/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/expected.json index f3ff470d0..1ca03f63e 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/expected.json @@ -22,5 +22,5 @@ "findings": [ {"findingId":"rotation-boundary-partial-record","classification":"insufficientEvidence","evidence":[{"artifactId":"task-sequence-rotation-boundary-current","startLine":1,"endLine":1},{"artifactId":"task-sequence-rotation-boundary-lo","startLine":1,"endLine":1}],"coverageGapArtifactIds":[],"serverCauseClaimed":false,"appOrPolicyCauseClaimed":false,"nativeAcceptanceClaimed":false,"boundedNextArtifact":{"logicalArtifactId":"client-task-sequence-smsts","pathClass":"client","reason":"Replay the controlled archived-to-current rotation join after final intake interfaces land."}} ], - "correlationBoundary": {"scope":"sourceLocalOnly","joinFields":[],"forbiddenJoinFields":["filename","path","timestamp","displayName"],"rotationOrder":["lo","current"]} + "correlationBoundary": {"scope":"sourceLocalOnly","joinFields":[],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"],"rotationOrder":["lo","current"]} } 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 index 28f8b5102..11eede7fd 100644 --- 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 @@ -33,5 +33,5 @@ "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"],"causeBoundary":"No application, policy, or server causality is inferred from this task sequence record."} + "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/terminal-preflight/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/terminal-preflight/expected.json index 06c072d9b..406b621f3 100644 --- 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 @@ -33,5 +33,5 @@ "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"]} + "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/unknown-profile/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/unknown-profile/expected.json index ca8db196e..c102800ae 100644 --- 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 @@ -17,5 +17,5 @@ "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"]} + "correlationBoundary": {"scope":"sourceLocalOnly","joinFields":[],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} } 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 index 428df3cbc..7e9e169b8 100644 --- 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 @@ -52,5 +52,5 @@ {"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"],"sameTimestampDoesNotJoin":true} + "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/winpe/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/winpe/expected.json index 71cb4336a..b629295d7 100644 --- 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 @@ -33,5 +33,5 @@ "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"]} + "correlationBoundary": {"scope":"clientOnly","joinFields":["executionId","taskSequencePackageId","advertisementId","runContext"],"forbiddenJoinFields":["filename","path","timestamp","displayName","component"]} } 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 index bb193b0f9..308fecb53 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -43,6 +43,20 @@ const STATE_CHAIN: [&str; 8] = [ ]; 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; @@ -757,6 +771,10 @@ fn validate_manifest_and_storage( 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" @@ -1160,6 +1178,50 @@ fn validate_contract( )); } + let declared_scope = expected["correlationBoundary"]["scope"] + .as_str() + .ok_or_else(|| format!("{scenario}: correlation scope is not a string"))?; + let declared_join_fields = string_array(&expected["correlationBoundary"]["joinFields"])?; + 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" + )); + } + if declared_join_fields != EXACT_KEY_JOIN_FIELDS.map(str::to_owned) { + 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() @@ -1167,19 +1229,14 @@ fn validate_contract( let key = transaction["key"] .as_object() .ok_or_else(|| format!("{transaction_id}: key is not an object"))?; - for required in [ - "executionId", - "taskSequencePackageId", - "advertisementId", - "runContext", - ] { + 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 ["filename", "path", "timestamp", "displayName", "component"] { + for forbidden in FORBIDDEN_JOIN_FIELDS { if key.contains_key(forbidden) { return Err(format!( "{transaction_id}: forbidden join field {forbidden}" From 657a04b901287b68866b6f096c80d005cadce536 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:19:12 -0400 Subject: [PATCH 193/422] test(sccm): pin derived mixed selection state Before this test the derived mixed selection state repair (f4f313dc) had only one committed probe and the review thread stayed open. The new test pins the derivation with exact-error assertions: upgrading the only unknown-version artifact, relabeling to unknownProfile, and removing the artifact outright must each fail with the profile selection error and nothing incidental, while the coherent selected projection of the surviving profile/offset sets is accepted. Against the pre-repair workflow-name arm this test fails (stale mixedUnknownAndInvalid returns Ok), proving it guards the exact hole. Refs #326 --- ...sccm_client_management_fixture_contract.rs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 1bed4b211..a7a622f46 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -3163,3 +3163,97 @@ fn duplicate_coverage_gap_ownership_mutations_fail_closed() { "duplicate coverage gap mutations were accepted: {accepted:?}" ); } + +#[test] +fn stale_mixed_selection_state_mutations_fail_closed() { + let stale_selection_error = "extraction profile identity/selection is invalid".to_owned(); + let (mixed_root, mixed_manifest, mixed_expected) = load_contract("mixed-unrelated"); + + let upgrade_unknown_version = |manifest: &Value| { + let mut upgraded = manifest.clone(); + let artifact = upgraded["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .iter_mut() + .find(|artifact| artifact["artifactId"] == "mixed-owner-unknown") + .expect("mixed corpus has the unknown-version artifact"); + artifact["sourceVersion"] = Value::String("5.00.TEST.3260".to_owned()); + upgraded + }; + let drop_unknown_profile_observation = |expected: &Value| { + let mut dropped = expected.clone(); + dropped["sourceLocalObservations"] + .as_array_mut() + .expect("observations are an array") + .retain(|observation| observation["kind"] != "unknownProfile"); + dropped + }; + + let upgraded_manifest = upgrade_unknown_version(&mixed_manifest); + let stale_mixed = drop_unknown_profile_observation(&mixed_expected); + assert_eq!( + validate_contract( + "mixed-unrelated", + &mixed_root, + &upgraded_manifest, + &stale_mixed, + ), + Err(stale_selection_error.clone()), + "stale mixedUnknownAndInvalid must fail on the derived selection state" + ); + + let mut stale_unknown_profile = drop_unknown_profile_observation(&mixed_expected); + stale_unknown_profile["extractionProfile"]["selectionState"] = + Value::String("unknownProfile".to_owned()); + assert_eq!( + validate_contract( + "mixed-unrelated", + &mixed_root, + &upgraded_manifest, + &stale_unknown_profile, + ), + Err(stale_selection_error.clone()), + "stale unknownProfile must also fail on the derived selection state" + ); + + let mut derived_selected = drop_unknown_profile_observation(&mixed_expected); + derived_selected["extractionProfile"]["selectionState"] = Value::String("selected".to_owned()); + assert_eq!( + validate_contract( + "mixed-unrelated", + &mixed_root, + &upgraded_manifest, + &derived_selected, + ), + Ok(()), + "the selected state derived from the surviving sets must be accepted" + ); + + let temporary = copy_scenario_to_temporary_root("mixed-unrelated", "remove-unknown-artifact"); + std::fs::remove_file( + temporary + .root + .join("evidence/client-co-management/current/CoManagementHandler.log"), + ) + .expect("temporary unknown-version evidence is removable"); + let mut removed_manifest = mixed_manifest.clone(); + removed_manifest["artifacts"] + .as_array_mut() + .expect("artifacts are an array") + .retain(|artifact| artifact["artifactId"] != "mixed-owner-unknown"); + let mut removed_expected = mixed_expected.clone(); + removed_expected["coverage"] + .as_array_mut() + .expect("coverage is an array") + .retain(|row| row["artifactId"] != "mixed-owner-unknown"); + assert_eq!( + validate_contract( + "mixed-unrelated", + &temporary.root, + &removed_manifest, + &removed_expected, + ), + Err(stale_selection_error), + "removing the only unknown-version artifact must flip the derived selection state" + ); +} From cea54d98596b932e73df55327a05efd4ffafc712 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:24:41 -0400 Subject: [PATCH 194/422] docs(sccm): attribute advanced-role proofs to their tests The catalog-fixture matrix only covers missing-required-field, redaction-required, unvalidated-source, and valid. Credit the unknown-value, RuleValidated admission, and deprecation claims to the dedicated tests that actually prove them. Refs #334 --- docs/sccm/source-catalog/advanced-roles.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/sccm/source-catalog/advanced-roles.md b/docs/sccm/source-catalog/advanced-roles.md index 1e876a3b6..59510b67b 100644 --- a/docs/sccm/source-catalog/advanced-roles.md +++ b/docs/sccm/source-catalog/advanced-roles.md @@ -45,15 +45,18 @@ Candidate names must be confirmed against configured role provenance before prom 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. Supersession metadata cannot promote a card. -The synthetic catalog-fixture matrix proves: +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; -- unknown parser and promotion values are retained for review and rejected; -- RuleValidated admission still requires validated, nonempty, deterministic key kinds; -- deprecation without an existing catalog successor is rejected, and deprecated cards remain outside semantic admission. +- 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 From dce737773f4ce57b0ffb9df5b83c8b49dff94be8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:25:03 -0400 Subject: [PATCH 195/422] test(sccm): extract advanced role admission predicate The semantic-admission expression was duplicated in validate_card and validate_card_with_inventory. Extract is_admitted so a future admission rule changes in one place. Refs #334 --- .../tests/sccm_server_advanced_roles_catalog.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs b/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs index 0c7e1dea0..d4170f096 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs @@ -313,6 +313,12 @@ fn nonempty_sorted(values: &[String]) -> bool { && 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(); @@ -557,9 +563,7 @@ fn validate_card(card: &SourceCard) -> Validation { issues.sort(); issues.dedup(); - let admitted_to_semantic_catalog = issues.is_empty() - && matches!(card.promotion.state, PromotionState::RuleValidated) - && card.supersession.state == SupersessionState::Active; + let admitted_to_semantic_catalog = is_admitted(card, &issues); Validation { valid: issues.is_empty(), admitted_to_semantic_catalog, @@ -583,9 +587,7 @@ fn validate_card_with_inventory(card: &SourceCard, inventory: &BTreeSet) validation.issues.sort(); validation.issues.dedup(); validation.valid = validation.issues.is_empty(); - validation.admitted_to_semantic_catalog = validation.valid - && matches!(card.promotion.state, PromotionState::RuleValidated) - && card.supersession.state == SupersessionState::Active; + validation.admitted_to_semantic_catalog = is_admitted(card, &validation.issues); validation } From 999f8613d570f96c15a4e880aa41f45216e59186 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:25:05 -0400 Subject: [PATCH 196/422] test(sccm): expose adjacent malformed-shape panics Before this change the panic-free shape battery covered only the enumerated shapes. Seven adjacent malformed-shape mutations still panic the validator instead of failing closed: counterpartReadyFacts as an object, correlationHandoff as a scalar, non-string transactionId, non-string finding subjectId, non-string transaction state, non-array prohibitedClaims, and string coverageGapArtifactIds. The panic sites are the counterpartReadyFacts expect and the json_string and string_array helpers called on caller-controlled JSON. Extend the shape-mutation battery with all seven shapes and add direct helper-level probes proving the helpers never panic, so the whole class is closed rather than an enumeration. Refs #323 --- .../sccm_client_updates_fixture_contract.rs | 94 ++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index ad2a23bb5..98bbbefcb 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -3366,7 +3366,7 @@ fn software_update_fixture_contract_rejects_phase_provenance_privacy_and_shape_m )); type ShapeMutation = (&'static str, fn(&mut Value), &'static str); - let shape_mutations: [ShapeMutation; 6] = [ + let shape_mutations: [ShapeMutation; 12] = [ ( "stateChain object", |expected: &mut Value| expected["stateChain"] = serde_json::json!({}), @@ -3399,6 +3399,43 @@ fn software_update_fixture_contract_rejects_phase_provenance_privacy_and_shape_m }, "validatedArtifactFamilies must be an array", ), + ( + "counterpartReadyFacts object", + |expected: &mut Value| { + expected["correlationHandoff"]["counterpartReadyFacts"] = serde_json::json!({}) + }, + "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(); @@ -3439,6 +3476,16 @@ fn software_update_fixture_contract_rejects_phase_provenance_privacy_and_shape_m "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) { @@ -3451,6 +3498,51 @@ fn software_update_fixture_contract_rejects_phase_provenance_privacy_and_shape_m } } + let malformed_shapes = serde_json::json!({ + "stringField": 323, + "arrayField": "not-an-array", + "mixedArray": ["ok", 323] + }); + let helper_probes: Vec<(&str, Box)> = 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{}", From a988ac9ffce2017aac84d0166c0d386ee9148f86 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:25:42 -0400 Subject: [PATCH 197/422] test(sccm): resolve supersedes against catalog inventory validate_card_with_inventory resolved superseded_by but ignored supersedes, so a card could name a predecessor no catalog card declares or one failing the card-id grammar. Apply the inventory rule to both edges, cover it in the deprecation test, and state the rule in the catalog doc. Refs #334 --- .../sccm_server_advanced_roles_catalog.rs | 32 +++++++++++++++++++ docs/sccm/source-catalog/advanced-roles.md | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs b/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs index d4170f096..8114214cd 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs @@ -584,6 +584,16 @@ fn validate_card_with_inventory(card: &SourceCard, inventory: &BTreeSet) .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(); @@ -779,6 +789,28 @@ fn deprecation_requires_an_explicit_successor_and_never_panics() { !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] diff --git a/docs/sccm/source-catalog/advanced-roles.md b/docs/sccm/source-catalog/advanced-roles.md index 59510b67b..3e0b730be 100644 --- a/docs/sccm/source-catalog/advanced-roles.md +++ b/docs/sccm/source-catalog/advanced-roles.md @@ -43,7 +43,7 @@ Candidate names must be confirmed against configured role provenance before prom ## 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. Supersession metadata cannot promote a card. +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: From 92a090b84e25c9341aa9c838ac1c5288666350b0 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:26:27 -0400 Subject: [PATCH 198/422] fix(sccm): fail closed on malformed validator shapes Before this change the json_string and string_array helpers and the counterpartReadyFacts expect panicked when caller-controlled JSON carried a wrong shape, so seven adjacent malformed-shape mutations crashed the validator instead of producing failure strings. Convert both helpers to return failure strings and route every call site (transaction and observation ids, finding subjectId, subject outcome fields, coverage entries, prohibited claims, same-minute update keys, coverage gap ids, manifest artifact ids, and the counterpart facts array) through the accumulated-failure path, so the entire malformed-shape class fails closed rather than only the enumerated shapes. Refs #323 --- .../sccm_client_updates_fixture_contract.rs | 151 +++++++++++------- 1 file changed, 94 insertions(+), 57 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 98bbbefcb..eea0d9c4b 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -377,27 +377,27 @@ fn scenario_directories() -> Vec { scenarios } -fn json_string(value: &Value, field: &str) -> String { +fn json_string(value: &Value, field: &str) -> Result { value[field] .as_str() - .unwrap_or_else(|| panic!("{field} must be a string")) - .to_owned() + .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) -> Vec { +fn string_array(values: &Value, field: &str) -> Result, String> { values[field] .as_array() - .unwrap_or_else(|| panic!("{field} must be an array")) + .ok_or_else(|| format!("{field} must be an array"))? .iter() .map(|value| { value .as_str() - .unwrap_or_else(|| panic!("{field} values must be strings")) - .to_owned() + .map(str::to_owned) + .ok_or_else(|| format!("{field} values must be strings")) }) .collect() } @@ -496,10 +496,13 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> findings.len() )); } - let transaction_ids = transactions - .iter() - .map(|transaction| json_string(transaction, "transactionId")) - .collect::>(); + 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 @@ -530,17 +533,23 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> )); } } - let subject_ids = transactions - .iter() - .map(|transaction| json_string(transaction, "transactionId")) - .chain( - observations - .iter() - .map(|observation| json_string(observation, "observationId")), - ) - .collect::>(); + 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 = json_string(finding, "subjectId"); + 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" @@ -555,9 +564,9 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> if contract.transactions + contract.observations > 0 { if let Some(subject) = subject(expected, contract) { if optional_json_string(subject, "phase").as_deref() != contract.phase - || json_string(subject, "state") != contract.state - || json_string(subject, "classification") != contract.classification - || json_string(subject, "confidenceCeiling") != contract.confidence_ceiling + || 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 { @@ -579,15 +588,18 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> failures.push(format!("{scenario}: coverage must be an array")); return failures; }; - let coverage_pairs = coverage - .iter() - .map(|entry| { - ( - json_string(entry, "logicalArtifactId"), - json_string(entry, "state"), - ) - }) - .collect::>(); + 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() @@ -600,9 +612,14 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> } let handoff = &expected["correlationHandoff"]; - let facts = handoff["counterpartReadyFacts"] - .as_array() - .expect("counterpartReadyFacts must be an array"); + 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 @@ -642,18 +659,23 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> } } - let prohibited = string_array(expected, "prohibitedClaims").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:?}" - )); + 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"]; @@ -691,10 +713,15 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> } } if scenario == "same-minute-separate" { - let update_ids = transactions - .iter() - .map(|transaction| json_string(&transaction["key"], "updateId")) - .collect::>(); + 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(), @@ -1218,11 +1245,15 @@ fn transaction_binding_failures( } } - let actual_gaps = string_array(transaction, "coverageGapArtifactIds"); - if actual_gaps != expected_transaction_gaps(scenario) { - failures.push(format!( - "{scenario}: coverage gaps drifted for {transaction_id}: {actual_gaps:?}" - )); + 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 @@ -2317,7 +2348,13 @@ fn software_update_fixture_bytes_paths_lines_and_ccm_records_are_exact() { .into_iter() .map(|failure| format!("{}: {failure}", contract.name)), ); - let artifact_id = json_string(artifact, "artifactId"); + 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; }; From 6a04e9f3fac7dded058e04aaff2caf23d1d4a6ab Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:27:19 -0400 Subject: [PATCH 199/422] test(sccm): expose cbsLog ConfigMgr sourceVersion gap Before this change the prep doc stated that CBS.log carries no ConfigMgr sourceVersion, yet the supplemental-conflict fixture shipped CBS.log with sourceVersion 5.00.TEST.0000 and the validator constrained sourceVersion to null only for the supplementalLog kind, so both null and non-null CBS values passed. Add a test proving a cbsLog artifact with a ConfigMgr sourceVersion is rejected by kind validation and by the full scenario validator, and pinning the shipped fixture value to null. Refs #323 --- .../sccm_client_updates_fixture_contract.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index eea0d9c4b..b2367a23a 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -2928,6 +2928,48 @@ fn software_update_fixture_rejects_install_failure_without_terminal_evidence() { ); } +#[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_cross_record_exact_key_chimeras() { let scenario_dir = updates_root().join("same-minute-separate"); From 271069f8491b8a1d1ece6ac9e69ffa1359202509 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:28:40 -0400 Subject: [PATCH 200/422] fix(sccm): bind cbsLog sourceVersion to null Before this change the supplemental-conflict fixture shipped CBS.log with a ConfigMgr sourceVersion of 5.00.TEST.0000 while the prep doc states neither CBS.log nor ReportingEvents.log carries one, and the validator forced null only for the supplementalLog kind. Key the null-sourceVersion constraint on the basename-derived expected kind so both cbsLog and supplementalLog artifacts reject any ConfigMgr sourceVersion, and null the shipped CBS.log fixture value so the corpus matches the documented supplemental servicing boundary. Refs #323 --- .../sccm/client/updates/supplemental-conflict/manifest.json | 2 +- .../tests/sccm_client_updates_fixture_contract.rs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 index e11700970..7c445562c 100644 --- 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 @@ -62,7 +62,7 @@ "kind": "current", "fragmentComplete": true }, - "sourceVersion": "5.00.TEST.0000", + "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/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index b2367a23a..01962652d 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -1559,9 +1559,11 @@ fn manifest_artifact_kind_failures(artifact: &Value) -> Vec { artifact["kind"] )); } - if artifact["kind"] == "supplementalLog" && !artifact["sourceVersion"].is_null() { + if matches!(expected_kind, "cbsLog" | "supplementalLog") + && !artifact["sourceVersion"].is_null() + { failures.push(format!( - "{artifact_id}: supplementalLog sourceVersion must be null" + "{artifact_id}: {expected_kind} sourceVersion must be null" )); } failures From f99750556d9f240346fb07f262bf08a452627bee Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:29:23 -0400 Subject: [PATCH 201/422] test(sccm): expose keyless observation phase claim Before this change the supplemental-conflict source-local CBS observation carried key null with keyConfidence none yet claimed lastSuccessfulPhase install, and its finding mirrored the claim. The prep doc requires every non-null lastSuccessfulPhase to be proven by a compatible cited complete record containing the exact key, which an unkeyed observation can never satisfy, but the validator accepted the claim. Add a test proving a null-key observation claiming lastSuccessfulPhase is rejected and pinning the shipped observation and finding values to null. Refs #323 --- .../sccm_client_updates_fixture_contract.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 01962652d..7467c3d89 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -2972,6 +2972,37 @@ fn software_update_fixture_rejects_cbs_log_with_configmgr_source_version() { ); } +#[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"); From ecf73d90b6e671b2ac017c5ffdaed321f1549d58 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:29:52 -0400 Subject: [PATCH 202/422] fix(sccm): null keyless observation phase claims Before this change a source-local observation with key null could carry a non-null lastSuccessfulPhase even though the corpus contract requires every non-null lastSuccessfulPhase to be proven by a cited complete record containing the exact key, which an unkeyed CBS symptom can never provide. Enforce null lastSuccessfulPhase on every keyless observation, null the supplemental-conflict observation and finding claims, and update the scenario contract so the keyed client transaction remains the only install-success result. Refs #323 --- .../client/updates/supplemental-conflict/expected.json | 4 ++-- .../tests/sccm_client_updates_fixture_contract.rs | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) 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 index d9e8a8e86..c49b469f6 100644 --- 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 @@ -104,7 +104,7 @@ "confidence": "low", "confidenceCeiling": "low", "correlationEligible": false, - "lastSuccessfulPhase": "install", + "lastSuccessfulPhase": null, "nextArtifact": { "logicalArtifactId": "client-windows-update-supplemental", "reason": "Collect the smallest bounded client-windows-update-supplemental continuation for this exact update subject." @@ -124,7 +124,7 @@ "subjectId": "updates:source-local:supplemental-conflict", "class": "lowConfidenceSymptom", "phase": "install", - "lastSuccessfulPhase": "install", + "lastSuccessfulPhase": null, "confidence": "low", "confidenceCeiling": "low", "nextArtifact": { diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 7467c3d89..9c3d909cd 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -340,7 +340,7 @@ const SCENARIOS: [ScenarioContract; 17] = [ state: "contradictory", classification: "lowConfidenceSymptom", confidence_ceiling: "low", - last_successful_phase: Some("install"), + last_successful_phase: None, next_artifact: Some("client-windows-update-supplemental"), coverage: &[ ("client-updates", "captured"), @@ -532,6 +532,11 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> "{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 { From 590608bf86de5d4af946bf6d1ed1961cf2133c69 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:30:16 -0400 Subject: [PATCH 203/422] test(sccm): pin scalar counterpartReadyFacts rejection Before the malformed-shape closure a scalar counterpartReadyFacts value panicked the validator at the counterpart facts expect, the same site as the object variant. The closure commit already routes every non-array counterpartReadyFacts value through the failure string path; add the scalar variant to the shape battery explicitly so object, scalar, and scalar-handoff forms are each pinned by a named mutation. Refs #323 --- .../tests/sccm_client_updates_fixture_contract.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 9c3d909cd..1afd92825 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -3483,7 +3483,7 @@ fn software_update_fixture_contract_rejects_phase_provenance_privacy_and_shape_m )); type ShapeMutation = (&'static str, fn(&mut Value), &'static str); - let shape_mutations: [ShapeMutation; 12] = [ + let shape_mutations: [ShapeMutation; 13] = [ ( "stateChain object", |expected: &mut Value| expected["stateChain"] = serde_json::json!({}), @@ -3523,6 +3523,14 @@ fn software_update_fixture_contract_rejects_phase_provenance_privacy_and_shape_m }, "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| { From 857cee2eb95d7a6cb23e0d66f5033305d42545eb Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:31:02 -0400 Subject: [PATCH 204/422] test(sccm): expose duplicate and overlapping citations Before this change evidence citations were only bounds-checked per tuple, so duplicating a transaction evidence citation in the success scenario, adding an overlapping updates-success-01-scan line 1-1 range beside the existing 1-2 range, and duplicating the supplemental-conflict observation citation all validated clean, double-counting the same logical CCM record for chronology and corroboration. Add a test proving each mutation is rejected, the class that blocked sibling lanes #326 and #375. Refs #323 --- .../sccm_client_updates_fixture_contract.rs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 1afd92825..1a4fa9ba2 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -2977,6 +2977,95 @@ fn software_update_fixture_rejects_cbs_log_with_configmgr_source_version() { ); } +#[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"; From f179d31846b94b8feec6dcc58545d43e598d7d0d Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:31:25 -0400 Subject: [PATCH 205/422] fix(sccm): reject overlapping evidence citation ranges Before this change citation validation only bounds-checked each tuple, so duplicate tuples and overlapping line ranges could cite the same logical CCM record twice within one evidence list and inflate chronology or corroboration. Expand every in-bounds cited range into (artifactId, line) identities with the same evidence_refs_cite_unique_records helper sibling lane #326 uses and fail closed inside citation_failures, closing the class for transaction, observation, finding, and counterpart citations alike. Refs #323 --- .../tests/sccm_client_updates_fixture_contract.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 1a4fa9ba2..8c336bab2 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -904,12 +904,20 @@ fn citation_triples( .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!( @@ -926,8 +934,15 @@ fn citation_failures( 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 } From 5dcc1f57b92e0fa2ea107e7d2fa65b50c7d10845 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:31:35 -0400 Subject: [PATCH 206/422] refactor(sccm): return correlation checks as results Convert the panic-based matrix, guard, and registry assertions into Result-returning validators so adversarial mutation probes can assert rejection without process aborts. Behavior-preserving: the six existing contract tests still pass unchanged. Refs #333 --- .../tests/sccm_correlation_contract.rs | 585 +++++++++++------- 1 file changed, 363 insertions(+), 222 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index 21e065d70..cb6fac85b 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -201,6 +201,27 @@ enum PairState { Candidate, } +struct MatrixSpec { + workflow: &'static str, + scenario_ids: &'static [&'static str], + client_issue: &'static str, + server_issue: &'static str, +} + +const POLICY_SPEC: MatrixSpec = MatrixSpec { + workflow: "policyManagementPoint", + scenario_ids: &POLICY_SCENARIOS, + client_issue: "#321", + server_issue: "#328", +}; + +const CONTENT_SPEC: MatrixSpec = MatrixSpec { + workflow: "contentDistributionPoint", + scenario_ids: &CONTENT_SCENARIOS, + client_issue: "#322", + server_issue: "#329", +}; + fn corpus_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/correlation") } @@ -213,11 +234,16 @@ fn repo_root() -> PathBuf { .to_path_buf() } -fn read_typed Deserialize<'de>>(path: &Path) -> T { +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!("{} is typed JSON: {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 is_sorted_unique(values: &[String]) -> bool { @@ -257,159 +283,342 @@ fn validate_fixture_ref(value: &str, issue: &str) -> bool { false } -fn assert_matrix_contract( - matrix: &ScenarioMatrix, - expected_workflow: &str, - expected_scenarios: &[&str], - client_issue: &str, - server_issue: &str, -) { - assert_eq!(matrix.schema_version, CONTRACT_SCHEMA_VERSION); - assert_eq!(matrix.workflow, expected_workflow); +fn check_scenario(scenario: &ScenarioContract, spec: &MatrixSpec) -> Result<(), String> { + let scenario_id = scenario.scenario_id.as_str(); + if scenario.client_issue != spec.client_issue { + return Err(format!("{scenario_id}: unexpected client issue")); + } + if scenario.server_issue != spec.server_issue { + return Err(format!("{scenario_id}: unexpected server issue")); + } + if !validate_issue(&scenario.client_issue) || !validate_issue(&scenario.server_issue) { + return Err(format!("{scenario_id}: malformed issue reference")); + } + if !validate_fixture_ref(&scenario.client_fixture_ref, &scenario.client_issue) { + return Err(format!( + "{scenario_id}: invalid client fixture ref {}", + scenario.client_fixture_ref + )); + } + if !validate_fixture_ref(&scenario.server_fixture_ref, &scenario.server_issue) { + return Err(format!( + "{scenario_id}: invalid server fixture ref {}", + scenario.server_fixture_ref + )); + } + if scenario.guard_ids.is_empty() || !is_sorted_unique(&scenario.guard_ids) { + return Err(format!( + "{scenario_id}: guard IDs must be nonempty, sorted, and unique" + )); + } + if !scenario + .guard_ids + .iter() + .all(|guard| GUARD_IDS.contains(&guard.as_str())) + { + return Err(format!("{scenario_id}: unknown guard")); + } + if scenario.expected.high_confidence_cause_allowed { + return Err(format!( + "{scenario_id}: adversarial fixture cannot permit high-confidence cause" + )); + } + if scenario.expected.exact_corroborated_allowed { + return Err(format!( + "{scenario_id}: adversarial fixture cannot permit ExactCorroborated" + )); + } + if scenario.expected.source_findings_mutable { + return Err(format!( + "{scenario_id}: source findings must stay immutable" + )); + } + if scenario.expected.link_strength_ceiling == "exactCorroborated" { + return Err(format!( + "{scenario_id}: link ceiling reached exactCorroborated" + )); + } + if scenario.expected.confidence_ceiling == "high" { + return Err(format!("{scenario_id}: confidence ceiling reached high")); + } + if !["candidate", "exactPartial", "incompatible", "unlinked"] + .contains(&scenario.expected.link_strength_ceiling.as_str()) + { + return Err(format!("{scenario_id}: unknown link strength ceiling")); + } + if !["low", "medium"].contains(&scenario.expected.confidence_ceiling.as_str()) { + return Err(format!("{scenario_id}: unknown confidence ceiling")); + } + if scenario.expected.reason_codes.is_empty() + || !is_sorted_unique(&scenario.expected.reason_codes) + { + return Err(format!( + "{scenario_id}: reason codes must be nonempty, sorted, and unique" + )); + } + if !is_sorted_unique(&scenario.expected.artifact_requests) { + return Err(format!( + "{scenario_id}: artifact requests must be sorted and unique" + )); + } + if scenario.expected.deterministic_result_id.is_empty() { + return Err(format!("{scenario_id}: deterministic result id is empty")); + } + + let public_json = serde_json::to_string(&scenario.expected_public_projection) + .expect("expected public projection serializes"); + for marker in &scenario.private_input_markers { + if public_json.contains(marker) { + return Err(format!( + "{scenario_id}: private marker leaked into expected public projection" + )); + } + } + if scenario.private_input_markers.is_empty() + != !scenario + .guard_ids + .contains(&"redaction-boundary".to_owned()) + { + return Err(format!( + "{scenario_id}: private markers and the redaction guard must be declared together" + )); + } + + if scenario.profile_state != ProfileState::Validated + && !scenario + .guard_ids + .iter() + .any(|guard| guard == "unknown-extraction-profile" || guard == "version-mismatch") + { + return Err(format!("{scenario_id}: unvalidated profile lacks a guard")); + } + if scenario.key_relation == KeyRelation::Missing + && !scenario.guard_ids.contains(&"same-time-no-key".to_owned()) + { + return Err(format!( + "{scenario_id}: missing key lacks the same-time guard" + )); + } + if scenario.key_relation == KeyRelation::Conflicting + && !scenario + .guard_ids + .contains(&"conflicting-exact-key".to_owned()) + { + return Err(format!("{scenario_id}: conflicting key lacks its guard")); + } + if scenario.key_relation == KeyRelation::VersionMismatch + && !scenario.guard_ids.contains(&"version-mismatch".to_owned()) + { + return Err(format!( + "{scenario_id}: key version mismatch lacks its guard" + )); + } + if scenario.topology == TopologyState::Incompatible + && !scenario + .guard_ids + .contains(&"incompatible-topology".to_owned()) + { + return Err(format!( + "{scenario_id}: incompatible topology lacks its guard" + )); + } + if scenario.timestamp_provenance == TimestampState::InvalidOffset + && !scenario + .guard_ids + .contains(&"invalid-timestamp-offset".to_owned()) + { + return Err(format!("{scenario_id}: invalid offset lacks its guard")); + } + if scenario.coverage == CoverageState::ClientOnly { + if !scenario + .guard_ids + .contains(&"missing-server-counterpart".to_owned()) + { + return Err(format!( + "{scenario_id}: client-only coverage lacks its guard" + )); + } + if scenario.expected.artifact_requests.is_empty() { + return Err(format!( + "{scenario_id}: client-only coverage must request server artifacts" + )); + } + } + if scenario.coverage == CoverageState::ServerOnly { + if !scenario + .guard_ids + .contains(&"missing-client-counterpart".to_owned()) + { + return Err(format!( + "{scenario_id}: server-only coverage lacks its guard" + )); + } + if scenario.expected.artifact_requests.is_empty() { + return Err(format!( + "{scenario_id}: server-only coverage must request client artifacts" + )); + } + } + if scenario.coverage == CoverageState::Partial + && !scenario.guard_ids.contains(&"partial-capture".to_owned()) + { + return Err(format!("{scenario_id}: partial coverage lacks its guard")); + } + if scenario.rotation == RotationState::Split + && !scenario.guard_ids.contains(&"rotation-split".to_owned()) + { + return Err(format!("{scenario_id}: split rotation lacks its guard")); + } + if scenario.terminal_relation == TerminalRelation::Unrelated + && !scenario + .guard_ids + .contains(&"unrelated-terminal-error".to_owned()) + { + return Err(format!("{scenario_id}: unrelated terminal lacks its guard")); + } + Ok(()) +} + +fn check_matrix_contract(matrix: &ScenarioMatrix, spec: &MatrixSpec) -> Result<(), String> { + if matrix.schema_version != CONTRACT_SCHEMA_VERSION { + return Err(format!( + "unexpected schema version {}", + matrix.schema_version + )); + } + if matrix.workflow != spec.workflow { + return Err(format!("unexpected workflow {}", matrix.workflow)); + } let scenario_ids = matrix .scenarios .iter() .map(|scenario| scenario.scenario_id.as_str()) .collect::>(); - assert_eq!(scenario_ids, expected_scenarios); + if scenario_ids != spec.scenario_ids { + return Err(format!("{}: scenario matrix changed", spec.workflow)); + } let mut exercised_guards = BTreeSet::new(); for scenario in &matrix.scenarios { - assert_eq!( - scenario.client_issue, client_issue, - "{}", - scenario.scenario_id - ); - assert_eq!( - scenario.server_issue, server_issue, - "{}", - scenario.scenario_id - ); - assert!(validate_issue(&scenario.client_issue)); - assert!(validate_issue(&scenario.server_issue)); - assert!( - validate_fixture_ref(&scenario.client_fixture_ref, &scenario.client_issue), - "{}: invalid client fixture ref {}", - scenario.scenario_id, - scenario.client_fixture_ref - ); - assert!( - validate_fixture_ref(&scenario.server_fixture_ref, &scenario.server_issue), - "{}: invalid server fixture ref {}", - scenario.scenario_id, - scenario.server_fixture_ref - ); - assert!( - !scenario.guard_ids.is_empty() && is_sorted_unique(&scenario.guard_ids), - "{}: guard IDs must be nonempty, sorted, and unique", - scenario.scenario_id - ); - assert!( - scenario - .guard_ids - .iter() - .all(|guard| GUARD_IDS.contains(&guard.as_str())), - "{}: unknown guard", - scenario.scenario_id - ); + check_scenario(scenario, spec)?; exercised_guards.extend(scenario.guard_ids.iter().map(String::as_str)); - assert!( - !scenario.expected.high_confidence_cause_allowed, - "{}: adversarial fixture cannot permit high-confidence cause", - scenario.scenario_id - ); - assert!( - !scenario.expected.exact_corroborated_allowed, - "{}: adversarial fixture cannot permit ExactCorroborated", - scenario.scenario_id - ); - assert!(!scenario.expected.source_findings_mutable); - assert_ne!(scenario.expected.link_strength_ceiling, "exactCorroborated"); - assert_ne!(scenario.expected.confidence_ceiling, "high"); - assert!(["candidate", "exactPartial", "incompatible", "unlinked"] - .contains(&scenario.expected.link_strength_ceiling.as_str())); - assert!(["low", "medium"].contains(&scenario.expected.confidence_ceiling.as_str())); - assert!( - !scenario.expected.reason_codes.is_empty() - && is_sorted_unique(&scenario.expected.reason_codes) - ); - assert!(is_sorted_unique(&scenario.expected.artifact_requests)); - assert!(!scenario.expected.deterministic_result_id.is_empty()); - - let public_json = serde_json::to_string(&scenario.expected_public_projection) - .expect("expected public projection serializes"); - for marker in &scenario.private_input_markers { - assert!( - !public_json.contains(marker), - "{}: private marker leaked into expected public projection", - scenario.scenario_id - ); - } - assert_eq!( - scenario.private_input_markers.is_empty(), - !scenario - .guard_ids - .contains(&"redaction-boundary".to_owned()), - "{}: private markers and the redaction guard must be declared together", - scenario.scenario_id - ); + } + if exercised_guards != GUARD_IDS.into_iter().collect() { + return Err(format!( + "{}: every shared guard needs a pair-specific adversarial scenario", + spec.workflow + )); + } + Ok(()) +} - if scenario.profile_state != ProfileState::Validated { - assert!(scenario - .guard_ids - .iter() - .any(|guard| guard == "unknown-extraction-profile" || guard == "version-mismatch")); - } - if scenario.key_relation == KeyRelation::Missing { - assert!(scenario.guard_ids.contains(&"same-time-no-key".to_owned())); +fn check_guard_matrix(matrix: &GuardMatrix) -> Result<(), String> { + if matrix.schema_version != CONTRACT_SCHEMA_VERSION { + return Err(format!( + "unexpected schema version {}", + matrix.schema_version + )); + } + let guard_ids = matrix + .guards + .iter() + .map(|guard| guard.guard_id.as_str()) + .collect::>(); + if guard_ids != GUARD_IDS { + return Err("shared guard list changed".to_owned()); + } + + for guard in &matrix.guards { + let guard_id = guard.guard_id.as_str(); + if guard.applies_to != WORKFLOWS { + return Err(format!("{guard_id}: guard must apply to both first pairs")); } - if scenario.key_relation == KeyRelation::Conflicting { - assert!(scenario - .guard_ids - .contains(&"conflicting-exact-key".to_owned())); + if guard.forbidden_strengths != ["exactCorroborated"] { + return Err(format!("{guard_id}: guard must forbid exactCorroborated")); } - if scenario.key_relation == KeyRelation::VersionMismatch { - assert!(scenario.guard_ids.contains(&"version-mismatch".to_owned())); + if guard.forbidden_confidences != ["high"] { + return Err(format!("{guard_id}: guard must forbid high confidence")); } - if scenario.topology == TopologyState::Incompatible { - assert!(scenario - .guard_ids - .contains(&"incompatible-topology".to_owned())); + if guard.required_outputs.is_empty() || !is_sorted_unique(&guard.required_outputs) { + return Err(format!( + "{guard_id}: required outputs must be nonempty, sorted, and unique" + )); } - if scenario.timestamp_provenance == TimestampState::InvalidOffset { - assert!(scenario - .guard_ids - .contains(&"invalid-timestamp-offset".to_owned())); + } + Ok(()) +} + +fn check_pair_registry(registry: &PairRegistry) -> Result<(), String> { + if registry.schema_version != CONTRACT_SCHEMA_VERSION { + return Err(format!( + "unexpected schema version {}", + registry.schema_version + )); + } + let pair_ids = registry + .pairs + .iter() + .map(|pair| pair.pair_id.as_str()) + .collect::>(); + if pair_ids + != [ + "content-distribution-point", + "policy-management-point", + "updates-software-update-point", + ] + { + return Err("pair registry membership changed".to_owned()); + } + + let mut workflow_states = BTreeMap::new(); + for pair in ®istry.pairs { + let pair_id = pair.pair_id.as_str(); + if !validate_issue(&pair.client_issue) || !validate_issue(&pair.server_issue) { + return Err(format!("{pair_id}: malformed issue reference")); } - if scenario.coverage == CoverageState::ClientOnly { - assert!(scenario - .guard_ids - .contains(&"missing-server-counterpart".to_owned())); - assert!(!scenario.expected.artifact_requests.is_empty()); + if pair.production_enabled { + return Err(format!("{pair_id}: production must stay disabled")); } - if scenario.coverage == CoverageState::ServerOnly { - assert!(scenario - .guard_ids - .contains(&"missing-client-counterpart".to_owned())); - assert!(!scenario.expected.artifact_requests.is_empty()); + if pair.rule_validated { + return Err(format!("{pair_id}: no pair may claim RuleValidated")); } - if scenario.coverage == CoverageState::Partial { - assert!(scenario.guard_ids.contains(&"partial-capture".to_owned())); + if pair.implementation_module.is_some() { + return Err(format!("{pair_id}: no implementation module is permitted")); } - if scenario.rotation == RotationState::Split { - assert!(scenario.guard_ids.contains(&"rotation-split".to_owned())); + if pair.required_guard_ids != GUARD_IDS { + return Err(format!("{pair_id}: required guard list changed")); } - if scenario.terminal_relation == TerminalRelation::Unrelated { - assert!(scenario - .guard_ids - .contains(&"unrelated-terminal-error".to_owned())); + if pair.blockers.is_empty() || !is_sorted_unique(&pair.blockers) { + return Err(format!( + "{pair_id}: blockers must be nonempty, sorted, and unique" + )); } + workflow_states.insert(pair.workflow.clone(), &pair.state); } - assert_eq!( - exercised_guards, - GUARD_IDS.into_iter().collect(), - "{expected_workflow}: every shared guard needs a pair-specific adversarial scenario" - ); + if workflow_states + .keys() + .map(String::as_str) + .collect::>() + != [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint", + ] + .into_iter() + .collect() + { + return Err("pair registry workflows changed".to_owned()); + } + if *workflow_states["contentDistributionPoint"] != PairState::ContractPrepared { + return Err("content pair left ContractPrepared".to_owned()); + } + if *workflow_states["policyManagementPoint"] != PairState::ContractPrepared { + return Err("policy pair left ContractPrepared".to_owned()); + } + if *workflow_states["updatesSoftwareUpdatePoint"] != PairState::Candidate { + return Err("updates pair must stay Candidate".to_owned()); + } + Ok(()) } #[test] @@ -424,48 +633,29 @@ fn correlation_preparation_contains_no_production_module() { #[test] fn shared_false_causality_guards_are_exact_and_pair_complete() { - let matrix: GuardMatrix = read_typed(&corpus_root().join("shared/adversarial-matrix.json")); - assert_eq!(matrix.schema_version, CONTRACT_SCHEMA_VERSION); - let guard_ids = matrix - .guards - .iter() - .map(|guard| guard.guard_id.as_str()) - .collect::>(); - assert_eq!(guard_ids, GUARD_IDS); - - for guard in matrix.guards { - assert_eq!(guard.applies_to, WORKFLOWS); - assert_eq!(guard.forbidden_strengths, ["exactCorroborated"]); - assert_eq!(guard.forbidden_confidences, ["high"]); - assert!(!guard.required_outputs.is_empty()); - assert!(is_sorted_unique(&guard.required_outputs)); - } + let matrix: GuardMatrix = typed(read_json( + &corpus_root().join("shared/adversarial-matrix.json"), + )) + .unwrap_or_else(|error| panic!("{error}")); + check_guard_matrix(&matrix).unwrap_or_else(|error| panic!("{error}")); } #[test] fn policy_to_management_point_adversarial_matrix_is_conservative() { - let matrix: ScenarioMatrix = - read_typed(&corpus_root().join("policy_management_point/adversarial-matrix.json")); - assert_matrix_contract( - &matrix, - "policyManagementPoint", - &POLICY_SCENARIOS, - "#321", - "#328", - ); + let matrix: ScenarioMatrix = typed(read_json( + &corpus_root().join("policy_management_point/adversarial-matrix.json"), + )) + .unwrap_or_else(|error| panic!("{error}")); + check_matrix_contract(&matrix, &POLICY_SPEC).unwrap_or_else(|error| panic!("{error}")); } #[test] fn content_to_distribution_point_adversarial_matrix_is_conservative() { - let matrix: ScenarioMatrix = - read_typed(&corpus_root().join("content_distribution_point/adversarial-matrix.json")); - assert_matrix_contract( - &matrix, - "contentDistributionPoint", - &CONTENT_SCENARIOS, - "#322", - "#329", - ); + let matrix: ScenarioMatrix = typed(read_json( + &corpus_root().join("content_distribution_point/adversarial-matrix.json"), + )) + .unwrap_or_else(|error| panic!("{error}")); + check_matrix_contract(&matrix, &CONTENT_SPEC).unwrap_or_else(|error| panic!("{error}")); } #[test] @@ -474,7 +664,8 @@ fn reordered_contracts_pin_identical_expected_results() { "policy_management_point/adversarial-matrix.json", "content_distribution_point/adversarial-matrix.json", ] { - let matrix: ScenarioMatrix = read_typed(&corpus_root().join(path)); + let matrix: ScenarioMatrix = + typed(read_json(&corpus_root().join(path))).unwrap_or_else(|error| panic!("{error}")); let reordered = matrix .scenarios .iter() @@ -491,57 +682,7 @@ fn reordered_contracts_pin_identical_expected_results() { #[test] fn pair_registry_is_non_executable_and_expansion_is_gated() { - let registry: PairRegistry = read_typed(&corpus_root().join("pair-registry.json")); - assert_eq!(registry.schema_version, CONTRACT_SCHEMA_VERSION); - let pair_ids = registry - .pairs - .iter() - .map(|pair| pair.pair_id.as_str()) - .collect::>(); - assert_eq!( - pair_ids, - [ - "content-distribution-point", - "policy-management-point", - "updates-software-update-point", - ] - ); - - let mut workflow_states = BTreeMap::new(); - for pair in registry.pairs { - assert!(validate_issue(&pair.client_issue)); - assert!(validate_issue(&pair.server_issue)); - assert!(!pair.production_enabled); - assert!(!pair.rule_validated); - assert!(pair.implementation_module.is_none()); - assert_eq!(pair.required_guard_ids, GUARD_IDS); - assert!(!pair.blockers.is_empty()); - assert!(is_sorted_unique(&pair.blockers)); - workflow_states.insert(pair.workflow, pair.state); - } - assert_eq!( - workflow_states - .keys() - .map(String::as_str) - .collect::>(), - [ - "contentDistributionPoint", - "policyManagementPoint", - "updatesSoftwareUpdatePoint", - ] - .into_iter() - .collect() - ); - assert_eq!( - workflow_states["contentDistributionPoint"], - PairState::ContractPrepared - ); - assert_eq!( - workflow_states["policyManagementPoint"], - PairState::ContractPrepared - ); - assert_eq!( - workflow_states["updatesSoftwareUpdatePoint"], - PairState::Candidate - ); + let registry: PairRegistry = typed(read_json(&corpus_root().join("pair-registry.json"))) + .unwrap_or_else(|error| panic!("{error}")); + check_pair_registry(®istry).unwrap_or_else(|error| panic!("{error}")); } From 0ec6abad5c6ea66629bda00dbf31a3657121b116 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:31:58 -0400 Subject: [PATCH 207/422] test(sccm): expose stale metadata on noncapture artifacts Before this change an absent or access-denied artifact could carry encoding, collectionLimit, or truncated metadata and still validate clean because artifact_provenance_projection silently nulled the fields instead of the validator rejecting them, letting noncapture artifacts retain stale physical provenance. Add a test proving each field is rejected on an absent artifact in the incomplete scenario and on the access-denied artifact, the class that blocked sibling lanes #324 and #352. Refs #323 --- .../sccm_client_updates_fixture_contract.rs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 8c336bab2..28c40a9fd 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -2992,6 +2992,83 @@ fn software_update_fixture_rejects_cbs_log_with_configmgr_source_version() { ); } +#[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"; From eb78c2c9d1def75b58eb66e49b790232324b9584 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:32:18 -0400 Subject: [PATCH 208/422] fix(sccm): reject noncapture physical metadata Before this change the noncapture manifest branch only rejected relativePath and nonzero bytesCopied, so absent, access-denied, skipped, unsupported, and parse-failed artifacts could carry stale encoding, collectionLimit, or truncated metadata that the provenance projection silently nulled. Reject each physical metadata field on every noncapture capture state so stale provenance fails closed, mirroring the sibling lane #324 noncapture idiom. Refs #323 --- .../tests/sccm_client_updates_fixture_contract.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index 28c40a9fd..a52527a08 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -2165,6 +2165,13 @@ fn manifest_artifact_failures(scenario_dir: &Path, artifact: &Value) -> Vec Date: Fri, 31 Jul 2026 16:32:44 -0400 Subject: [PATCH 209/422] test(sccm): probe decoded projection privacy leaks Add mutation probes proving the redaction contract inspects only serialized JSON bytes: a decoded LAB backslash marker value, an undeclared raw Windows log path, and a marker hidden in a projection key all pass because serde escaping defeats the byte containment check. Fails until the projection walk is decoded. Refs #333 --- .../tests/sccm_correlation_contract.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index cb6fac85b..20e58c586 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -621,6 +621,53 @@ fn check_pair_registry(registry: &PairRegistry) -> Result<(), String> { Ok(()) } +const POLICY_MATRIX_FIXTURE: &str = "policy_management_point/adversarial-matrix.json"; +const CONTENT_MATRIX_FIXTURE: &str = "content_distribution_point/adversarial-matrix.json"; + +fn check_mutated_matrix( + fixture: &str, + spec: &MatrixSpec, + mutate: impl FnOnce(&mut Value), +) -> Result<(), String> { + let mut value = read_json(&corpus_root().join(fixture)); + mutate(&mut value); + let matrix: ScenarioMatrix = typed(value)?; + check_matrix_contract(&matrix, spec) +} + +fn scenario_slot<'a>(matrix: &'a mut Value, scenario_id: &str) -> &'a mut Value { + matrix["scenarios"] + .as_array_mut() + .expect("scenario matrix fixture has a scenarios array") + .iter_mut() + .find(|scenario| scenario["scenarioId"] == scenario_id) + .unwrap_or_else(|| panic!("scenario {scenario_id} exists in the fixture")) +} + +#[test] +fn adversarial_projection_privacy_mutations_fail_closed() { + let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { + scenario_slot(matrix, "policy-redaction")["expectedPublicProjection"]["rawIdentity"] = + Value::String("LAB\\SyntheticUser".to_owned()); + }) + .expect_err("a decoded declared private marker cannot enter the public projection"); + assert!(error.contains("policy-redaction"), "{error}"); + + let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { + scenario_slot(matrix, "policy-invalid-offset")["expectedPublicProjection"] + ["evidencePath"] = Value::String("C:\\Windows\\CCM\\Logs\\PolicyAgent.log".to_owned()); + }) + .expect_err("an undeclared raw Windows path cannot enter the public projection"); + assert!(error.contains("policy-invalid-offset"), "{error}"); + + let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { + scenario_slot(matrix, "policy-redaction")["expectedPublicProjection"] + ["LAB\\SyntheticUser"] = Value::Bool(true); + }) + .expect_err("a decoded private marker cannot hide inside a projection key"); + assert!(error.contains("policy-redaction"), "{error}"); +} + #[test] fn correlation_preparation_contains_no_production_module() { assert!( From 96898b632f4322ba948ee18298ba330589c7b060 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:33:20 -0400 Subject: [PATCH 210/422] fix(sccm): inspect decoded projection strings fail closed Walk the expected public projection as a decoded JSON value tree: every object key and string value must fit a closed public grammar (ascii alphanumerics, dash, dot, max 96 bytes) and no decoded string may contain a declared private marker. Escaped backslash identities, raw Windows paths, and marker-shaped keys now fail. Refs #333 --- .../tests/sccm_correlation_contract.rs | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index 20e58c586..aa4be6c92 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -283,6 +283,32 @@ fn validate_fixture_ref(value: &str, issue: &str) -> bool { false } +fn collect_decoded_strings(value: &Value, sink: &mut Vec) { + match value { + Value::String(text) => sink.push(text.clone()), + Value::Array(items) => { + for item in items { + collect_decoded_strings(item, sink); + } + } + Value::Object(entries) => { + for (key, item) in entries { + sink.push(key.clone()); + collect_decoded_strings(item, sink); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn projection_string_is_safe(value: &str) -> bool { + !value.is_empty() + && value.len() <= 96 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.')) +} + fn check_scenario(scenario: &ScenarioContract, spec: &MatrixSpec) -> Result<(), String> { let scenario_id = scenario.scenario_id.as_str(); if scenario.client_issue != spec.client_issue { @@ -365,12 +391,22 @@ fn check_scenario(scenario: &ScenarioContract, spec: &MatrixSpec) -> Result<(), return Err(format!("{scenario_id}: deterministic result id is empty")); } - let public_json = serde_json::to_string(&scenario.expected_public_projection) - .expect("expected public projection serializes"); + let mut decoded_strings = Vec::new(); + collect_decoded_strings(&scenario.expected_public_projection, &mut decoded_strings); + for text in &decoded_strings { + if !projection_string_is_safe(text) { + return Err(format!( + "{scenario_id}: decoded projection string {text:?} is outside the closed public grammar" + )); + } + } for marker in &scenario.private_input_markers { - if public_json.contains(marker) { + if marker.is_empty() { + return Err(format!("{scenario_id}: private marker is empty")); + } + if decoded_strings.iter().any(|text| text.contains(marker)) { return Err(format!( - "{scenario_id}: private marker leaked into expected public projection" + "{scenario_id}: private marker leaked into a decoded projection value" )); } } From 6ccb367cf9fb4d52a440962968c907e0fb917113 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:33:25 -0400 Subject: [PATCH 211/422] fix(sccm): alias helper probe type for clippy Before this change the malformed-shape helper probe vector used an inline tuple type that trips the clippy type_complexity gate under -D warnings. Name the probe tuple with a lifetime-parameterized type alias; behavior is unchanged. Refs #323 --- .../tests/sccm_client_updates_fixture_contract.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index a52527a08..e0759696e 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -3816,7 +3816,8 @@ fn software_update_fixture_contract_rejects_phase_provenance_privacy_and_shape_m "arrayField": "not-an-array", "mixedArray": ["ok", 323] }); - let helper_probes: Vec<(&str, Box)> = vec![ + type HelperProbe<'probe> = (&'probe str, Box); + let helper_probes: Vec> = vec![ ( "json_string on non-string field", Box::new(|| { From a1d34a1b460dc94998fc1fff59f64e7bb2c2a3f5 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:34:09 -0400 Subject: [PATCH 212/422] fix(sccm): apply rustfmt to the updates contract test Before this change five constructs added during the review-fix round drifted from rustfmt layout in the updates contract test. Run rustfmt on that single changed file so the format gate passes; no behavioral change. Refs #323 --- .../sccm_client_updates_fixture_contract.rs | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs index e0759696e..ece82cc80 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates_fixture_contract.rs @@ -621,7 +621,9 @@ fn expected_boundary_failures(expected: &Value, contract: &ScenarioContract) -> let facts = match handoff["counterpartReadyFacts"].as_array() { Some(facts) => facts, None => { - failures.push(format!("{scenario}: counterpartReadyFacts must be an array")); + failures.push(format!( + "{scenario}: counterpartReadyFacts must be an array" + )); &empty_facts } }; @@ -906,9 +908,11 @@ fn citation_triples( 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))) - }) + 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( @@ -1579,8 +1583,7 @@ fn manifest_artifact_kind_failures(artifact: &Value) -> Vec { artifact["kind"] )); } - if matches!(expected_kind, "cbsLog" | "supplementalLog") - && !artifact["sourceVersion"].is_null() + if matches!(expected_kind, "cbsLog" | "supplementalLog") && !artifact["sourceVersion"].is_null() { failures.push(format!( "{artifact_id}: {expected_kind} sourceVersion must be null" @@ -3721,14 +3724,14 @@ fn software_update_fixture_contract_rejects_phase_provenance_privacy_and_shape_m ), ( "correlationHandoff scalar", - |expected: &mut Value| { - expected["correlationHandoff"] = serde_json::json!("no-handoff") - }, + |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), + |expected: &mut Value| { + expected["transactions"][0]["transactionId"] = serde_json::json!(323) + }, "transactionId must be a string", ), ( From 0bd7a176e4a41791337b6d373e2cdb44f10b06f9 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:34:17 -0400 Subject: [PATCH 213/422] test(sccm): probe unbound fixture refs and ownership Add mutation probes proving fixture refs are unconditional: the pending #329 server side accepts a merged client repo dir or a synthetic ref, a policy server slot accepts a client-side repo dir or a pending issue ref, and the registry accepts a rewritten content server issue or a dropped #329 acceptance blocker. Refs #333 --- .../tests/sccm_correlation_contract.rs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index aa4be6c92..f033ebbef 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -680,6 +680,72 @@ fn scenario_slot<'a>(matrix: &'a mut Value, scenario_id: &str) -> &'a mut Value .unwrap_or_else(|| panic!("scenario {scenario_id} exists in the fixture")) } +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); + let registry: PairRegistry = typed(value)?; + check_pair_registry(®istry) +} + +fn pair_slot<'a>(registry: &'a mut Value, pair_id: &str) -> &'a mut Value { + registry["pairs"] + .as_array_mut() + .expect("pair registry fixture has a pairs array") + .iter_mut() + .find(|pair| pair["pairId"] == pair_id) + .unwrap_or_else(|| panic!("pair {pair_id} exists in the registry")) +} + +#[test] +fn adversarial_fixture_ref_and_ownership_mutations_fail_closed() { + let error = check_mutated_matrix(CONTENT_MATRIX_FIXTURE, &CONTENT_SPEC, |matrix| { + scenario_slot(matrix, "content-invalid-offset")["serverFixtureRef"] = Value::String( + "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success" + .to_owned(), + ); + }) + .expect_err("the pending #329 server corpus cannot be replaced by a merged client corpus"); + assert!(error.contains("content-invalid-offset"), "{error}"); + + let error = check_mutated_matrix(CONTENT_MATRIX_FIXTURE, &CONTENT_SPEC, |matrix| { + scenario_slot(matrix, "content-conflicting-key")["serverFixtureRef"] = + Value::String("synthetic:content-divergent-server".to_owned()); + }) + .expect_err("the pending #329 server side cannot dodge into a synthetic ref"); + assert!(error.contains("content-conflicting-key"), "{error}"); + + let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { + scenario_slot(matrix, "policy-conflicting-key")["serverFixtureRef"] = Value::String( + "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete".to_owned(), + ); + }) + .expect_err("a server fixture ref cannot cite a client-side corpus directory"); + assert!(error.contains("policy-conflicting-key"), "{error}"); + + let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { + scenario_slot(matrix, "policy-conflicting-key")["serverFixtureRef"] = + Value::String("issue:#328:healthy-policy".to_owned()); + }) + .expect_err("a merged upstream side cannot claim a pending issue ref"); + assert!(error.contains("policy-conflicting-key"), "{error}"); + + let error = check_mutated_registry(|registry| { + pair_slot(registry, "content-distribution-point")["serverIssue"] = + Value::String("#328".to_owned()); + }) + .expect_err("the content pair server issue is pinned to #329"); + assert!(error.contains("content-distribution-point"), "{error}"); + + let error = check_mutated_registry(|registry| { + pair_slot(registry, "content-distribution-point")["blockers"] = serde_json::json!([ + "#318 finding interface exact-head review pending", + "#322 public fact interface not implemented" + ]); + }) + .expect_err("the content pair must stay honest about #329 pending acceptance"); + assert!(error.contains("content-distribution-point"), "{error}"); +} + #[test] fn adversarial_projection_privacy_mutations_fail_closed() { let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { From 56ebce4f3b19972e917c46865fa4e78d2aba4284 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:35:16 -0400 Subject: [PATCH 214/422] fix(sccm): bind fixture refs to side and upstream state Give each pair side an explicit corpus state: merged sides must cite repo dirs under their own side prefix or synthetic inputs, while the pending #329 content server side may only use issue:#329 refs or absent. Pin registry pair ownership (#322 to #329, #321 to #328, #323 to #330) and keep the #329 pending acceptance blocker declared. Refs #333 --- .../tests/sccm_correlation_contract.rs | 151 ++++++++++++++---- 1 file changed, 124 insertions(+), 27 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index f033ebbef..31349cc59 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -201,11 +201,24 @@ enum PairState { Candidate, } +/// Upstream acceptance state of one side of a correlation pair. +/// +/// `Merged` sides may only cite already merged synthetic corpus directories +/// under their own side prefix (or pair-local `synthetic:` inputs). `Pending` +/// sides have no accepted corpus on the program baseline, so they must stay +/// honestly marked with `issue:` refs until the upstream issue is accepted. +enum SideCorpus { + Merged { repo_prefix: &'static str }, + Pending { issue: &'static str }, +} + struct MatrixSpec { workflow: &'static str, scenario_ids: &'static [&'static str], client_issue: &'static str, server_issue: &'static str, + client_side: SideCorpus, + server_side: SideCorpus, } const POLICY_SPEC: MatrixSpec = MatrixSpec { @@ -213,6 +226,12 @@ const POLICY_SPEC: MatrixSpec = MatrixSpec { scenario_ids: &POLICY_SCENARIOS, client_issue: "#321", server_issue: "#328", + client_side: SideCorpus::Merged { + repo_prefix: "crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/", + }, + server_side: SideCorpus::Merged { + repo_prefix: "crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/", + }, }; const CONTENT_SPEC: MatrixSpec = MatrixSpec { @@ -220,8 +239,37 @@ const CONTENT_SPEC: MatrixSpec = MatrixSpec { scenario_ids: &CONTENT_SCENARIOS, client_issue: "#322", server_issue: "#329", + client_side: SideCorpus::Merged { + repo_prefix: "crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/", + }, + // #329 DP evidence is not independently accepted yet, so the server side + // may only name pending scenarios and must never cite a merged corpus. + server_side: SideCorpus::Pending { issue: "#329" }, }; +const PAIR_OWNERSHIP: [(&str, &str, &str, &str); 3] = [ + ( + "content-distribution-point", + "contentDistributionPoint", + "#322", + "#329", + ), + ( + "policy-management-point", + "policyManagementPoint", + "#321", + "#328", + ), + ( + "updates-software-update-point", + "updatesSoftwareUpdatePoint", + "#323", + "#330", + ), +]; + +const CONTENT_PENDING_BLOCKER: &str = "#329 public fact interface not independently accepted"; + fn corpus_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/correlation") } @@ -256,31 +304,66 @@ fn validate_issue(value: &str) -> bool { }) } -fn validate_fixture_ref(value: &str, issue: &str) -> bool { +fn is_synthetic_slug(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +fn check_fixture_ref(value: &str, side: &SideCorpus, side_name: &str) -> Result<(), String> { if value == "absent" { - return true; + return Ok(()); } if let Some(path) = value.strip_prefix("repo:") { - return !path.contains("..") && repo_root().join(path).is_dir(); + let SideCorpus::Merged { repo_prefix } = side else { + return Err(format!( + "{side_name} fixture ref {value} cites a merged corpus while the upstream side is pending" + )); + }; + if !path.starts_with(repo_prefix) { + return Err(format!( + "{side_name} fixture ref {value} is outside the side corpus {repo_prefix}" + )); + } + if path.contains("..") || !repo_root().join(path).is_dir() { + return Err(format!( + "{side_name} fixture ref {value} does not name a merged corpus directory" + )); + } + return Ok(()); } if let Some(synthetic_id) = value.strip_prefix("synthetic:") { - return !synthetic_id.is_empty() - && synthetic_id - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); + if !matches!(side, SideCorpus::Merged { .. }) { + return Err(format!( + "{side_name} fixture ref {value} hides a pending upstream side behind a synthetic input" + )); + } + if !is_synthetic_slug(synthetic_id) { + return Err(format!( + "{side_name} fixture ref {value} has a malformed synthetic slug" + )); + } + return Ok(()); } if let Some(pending) = value.strip_prefix("issue:") { - return pending + let SideCorpus::Pending { issue } = side else { + return Err(format!( + "{side_name} fixture ref {value} claims a pending upstream while the side corpus is merged" + )); + }; + let valid = pending .strip_prefix(issue) .and_then(|rest| rest.strip_prefix(':')) - .is_some_and(|scenario| { - !scenario.is_empty() - && scenario.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' - }) - }); + .is_some_and(is_synthetic_slug); + if !valid { + return Err(format!( + "{side_name} fixture ref {value} is not a pending {issue} scenario" + )); + } + return Ok(()); } - false + Err(format!("{side_name} fixture ref {value} has no known form")) } fn collect_decoded_strings(value: &Value, sink: &mut Vec) { @@ -320,18 +403,10 @@ fn check_scenario(scenario: &ScenarioContract, spec: &MatrixSpec) -> Result<(), if !validate_issue(&scenario.client_issue) || !validate_issue(&scenario.server_issue) { return Err(format!("{scenario_id}: malformed issue reference")); } - if !validate_fixture_ref(&scenario.client_fixture_ref, &scenario.client_issue) { - return Err(format!( - "{scenario_id}: invalid client fixture ref {}", - scenario.client_fixture_ref - )); - } - if !validate_fixture_ref(&scenario.server_fixture_ref, &scenario.server_issue) { - return Err(format!( - "{scenario_id}: invalid server fixture ref {}", - scenario.server_fixture_ref - )); - } + check_fixture_ref(&scenario.client_fixture_ref, &spec.client_side, "client") + .map_err(|error| format!("{scenario_id}: {error}"))?; + check_fixture_ref(&scenario.server_fixture_ref, &spec.server_side, "server") + .map_err(|error| format!("{scenario_id}: {error}"))?; if scenario.guard_ids.is_empty() || !is_sorted_unique(&scenario.guard_ids) { return Err(format!( "{scenario_id}: guard IDs must be nonempty, sorted, and unique" @@ -612,6 +687,28 @@ fn check_pair_registry(registry: &PairRegistry) -> Result<(), String> { if !validate_issue(&pair.client_issue) || !validate_issue(&pair.server_issue) { return Err(format!("{pair_id}: malformed issue reference")); } + let (_, workflow, client_issue, server_issue) = PAIR_OWNERSHIP + .iter() + .find(|(owner_id, _, _, _)| *owner_id == pair_id) + .ok_or_else(|| format!("{pair_id}: pair has no pinned ownership"))?; + if pair.workflow != *workflow { + return Err(format!("{pair_id}: workflow ownership changed")); + } + if pair.client_issue != *client_issue || pair.server_issue != *server_issue { + return Err(format!( + "{pair_id}: issue ownership must stay {client_issue} to {server_issue}" + )); + } + if pair_id == "content-distribution-point" + && !pair + .blockers + .iter() + .any(|blocker| blocker == CONTENT_PENDING_BLOCKER) + { + return Err(format!( + "{pair_id}: the #329 pending acceptance blocker must stay declared" + )); + } if pair.production_enabled { return Err(format!("{pair_id}: production must stay disabled")); } From a604401b5f29daec93b482c596a004e0c2b0c8be Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:36:43 -0400 Subject: [PATCH 215/422] test(sccm): probe neutralized guard input states Add the review's mutation set: every guard label survives with its adversarial input neutralized (missing to exact, conflicting to exact, incompatible to compatible, mismatch and unknown to validated, invalid offset to usable, split to complete, partial to complete, unrelated to corroborating) and an absent server ref survives a complete-coverage claim. All must fail closed. Refs #333 --- .../tests/sccm_correlation_contract.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index 31349cc59..c65bba00b 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -843,6 +843,49 @@ fn adversarial_fixture_ref_and_ownership_mutations_fail_closed() { assert!(error.contains("content-distribution-point"), "{error}"); } +#[test] +fn adversarial_neutralized_guard_state_mutations_fail_closed() { + let neutralizations: [(&str, &[(&str, &str)]); 9] = [ + ("policy-same-time-no-key", &[("keyRelation", "exact")]), + ("policy-conflicting-key", &[("keyRelation", "exact")]), + ("policy-topology-mismatch", &[("topology", "compatible")]), + ("policy-unknown-profile", &[("profileState", "validated")]), + ( + "policy-invalid-offset", + &[("timestampProvenance", "usable")], + ), + ("policy-rotation-split", &[("rotation", "complete")]), + ("policy-partial-capture", &[("coverage", "complete")]), + ( + "policy-unrelated-terminal-error", + &[("terminalRelation", "corroborating")], + ), + ( + "policy-version-mismatch", + &[("profileState", "validated"), ("keyRelation", "exact")], + ), + ]; + for (scenario_id, edits) in neutralizations { + let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { + let scenario = scenario_slot(matrix, scenario_id); + for (field, neutral) in edits { + scenario[*field] = Value::String((*neutral).to_owned()); + } + }) + .expect_err(&format!( + "{scenario_id}: neutralized inputs cannot keep the guard label green" + )); + assert!(error.contains(scenario_id), "{error}"); + } + + let error = check_mutated_matrix(CONTENT_MATRIX_FIXTURE, &CONTENT_SPEC, |matrix| { + scenario_slot(matrix, "content-client-only")["coverage"] = + Value::String("complete".to_owned()); + }) + .expect_err("an absent server counterpart cannot claim complete coverage"); + assert!(error.contains("content-client-only"), "{error}"); +} + #[test] fn adversarial_projection_privacy_mutations_fail_closed() { let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { From 29872eec8c3a46e63b3b3d70e8e712c726f184fc Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:37:50 -0400 Subject: [PATCH 216/422] fix(sccm): require every guard demonstrated by inputs Bind guard labels to input state in both directions: each of the thirteen guards has an exact demonstration predicate over the scenario inputs, a declared guard whose inputs were neutralized is rejected, a demonstrated guard that is undeclared is rejected, and absent fixture refs must agree with one-sided coverage. Refs #333 --- .../tests/sccm_correlation_contract.rs | 123 ++++++++++-------- 1 file changed, 68 insertions(+), 55 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index c65bba00b..71d204e32 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -510,13 +510,6 @@ fn check_scenario(scenario: &ScenarioContract, spec: &MatrixSpec) -> Result<(), "{scenario_id}: missing key lacks the same-time guard" )); } - if scenario.key_relation == KeyRelation::Conflicting - && !scenario - .guard_ids - .contains(&"conflicting-exact-key".to_owned()) - { - return Err(format!("{scenario_id}: conflicting key lacks its guard")); - } if scenario.key_relation == KeyRelation::VersionMismatch && !scenario.guard_ids.contains(&"version-mismatch".to_owned()) { @@ -524,70 +517,90 @@ fn check_scenario(scenario: &ScenarioContract, spec: &MatrixSpec) -> Result<(), "{scenario_id}: key version mismatch lacks its guard" )); } - if scenario.topology == TopologyState::Incompatible - && !scenario - .guard_ids - .contains(&"incompatible-topology".to_owned()) + if scenario.coverage == CoverageState::ClientOnly + && scenario.expected.artifact_requests.is_empty() { return Err(format!( - "{scenario_id}: incompatible topology lacks its guard" + "{scenario_id}: client-only coverage must request server artifacts" )); } - if scenario.timestamp_provenance == TimestampState::InvalidOffset - && !scenario - .guard_ids - .contains(&"invalid-timestamp-offset".to_owned()) + if scenario.coverage == CoverageState::ServerOnly + && scenario.expected.artifact_requests.is_empty() { - return Err(format!("{scenario_id}: invalid offset lacks its guard")); + return Err(format!( + "{scenario_id}: server-only coverage must request client artifacts" + )); } - if scenario.coverage == CoverageState::ClientOnly { - if !scenario - .guard_ids - .contains(&"missing-server-counterpart".to_owned()) - { + + if (scenario.client_fixture_ref == "absent") != (scenario.coverage == CoverageState::ServerOnly) + { + return Err(format!( + "{scenario_id}: an absent client fixture ref must match server-only coverage" + )); + } + if (scenario.server_fixture_ref == "absent") != (scenario.coverage == CoverageState::ClientOnly) + { + return Err(format!( + "{scenario_id}: an absent server fixture ref must match client-only coverage" + )); + } + for guard in GUARD_IDS { + let declared = scenario.guard_ids.iter().any(|declared| declared == guard); + let demonstrated = guard_demonstrated(guard, scenario); + if declared && !demonstrated { return Err(format!( - "{scenario_id}: client-only coverage lacks its guard" + "{scenario_id}: guard {guard} is declared but not demonstrated by the inputs" )); } - if scenario.expected.artifact_requests.is_empty() { + if demonstrated && !declared { return Err(format!( - "{scenario_id}: client-only coverage must request server artifacts" + "{scenario_id}: inputs demonstrate guard {guard} which is not declared" )); } } - if scenario.coverage == CoverageState::ServerOnly { - if !scenario - .guard_ids - .contains(&"missing-client-counterpart".to_owned()) - { - return Err(format!( - "{scenario_id}: server-only coverage lacks its guard" - )); + Ok(()) +} + +/// True when the scenario's own input state instantiates the guard's +/// adversarial construction. Every declared guard must be demonstrated by +/// the inputs, and every demonstrated guard must be declared, so a guard +/// label can never outlive a neutralized input. +fn guard_demonstrated(guard: &str, scenario: &ScenarioContract) -> bool { + match guard { + "conflicting-exact-key" => scenario.key_relation == KeyRelation::Conflicting, + "incompatible-topology" => scenario.topology == TopologyState::Incompatible, + "invalid-timestamp-offset" => { + scenario.timestamp_provenance == TimestampState::InvalidOffset } - if scenario.expected.artifact_requests.is_empty() { - return Err(format!( - "{scenario_id}: server-only coverage must request client artifacts" - )); + "missing-client-counterpart" => { + scenario.coverage == CoverageState::ServerOnly + && scenario.client_fixture_ref == "absent" + && scenario.server_fixture_ref != "absent" } + "missing-server-counterpart" => { + scenario.coverage == CoverageState::ClientOnly + && scenario.server_fixture_ref == "absent" + && scenario.client_fixture_ref != "absent" + } + "partial-capture" => scenario.coverage == CoverageState::Partial, + "redaction-boundary" => !scenario.private_input_markers.is_empty(), + "reordered-input" => { + scenario.scenario_id.ends_with("-reordered-input-a") + || scenario.scenario_id.ends_with("-reordered-input-b") + } + "rotation-split" => scenario.rotation == RotationState::Split, + "same-time-no-key" => { + scenario.key_relation == KeyRelation::Missing + && scenario.timestamp_provenance == TimestampState::Usable + } + "unknown-extraction-profile" => scenario.profile_state == ProfileState::Unknown, + "unrelated-terminal-error" => scenario.terminal_relation == TerminalRelation::Unrelated, + "version-mismatch" => { + scenario.profile_state == ProfileState::VersionMismatch + && scenario.key_relation == KeyRelation::VersionMismatch + } + _ => false, } - if scenario.coverage == CoverageState::Partial - && !scenario.guard_ids.contains(&"partial-capture".to_owned()) - { - return Err(format!("{scenario_id}: partial coverage lacks its guard")); - } - if scenario.rotation == RotationState::Split - && !scenario.guard_ids.contains(&"rotation-split".to_owned()) - { - return Err(format!("{scenario_id}: split rotation lacks its guard")); - } - if scenario.terminal_relation == TerminalRelation::Unrelated - && !scenario - .guard_ids - .contains(&"unrelated-terminal-error".to_owned()) - { - return Err(format!("{scenario_id}: unrelated terminal lacks its guard")); - } - Ok(()) } fn check_matrix_contract(matrix: &ScenarioMatrix, spec: &MatrixSpec) -> Result<(), String> { From 42f9f326206a99cfd003dca2ece37e7bfc9b9a72 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:38:36 -0400 Subject: [PATCH 217/422] test(sccm): pin opposite-order reorder evidence Require the A/B reordered scenarios to carry an ordered input evidence manifest: same multiset, B replaying A in opposite order, both sides present, identical fixture refs and input state, and one deterministic serialization and result id. Fails because the scenarios encode no reordered inputs at all. Refs #333 --- .../tests/sccm_correlation_contract.rs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index 71d204e32..94af5ab7b 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -856,6 +856,93 @@ fn adversarial_fixture_ref_and_ownership_mutations_fail_closed() { assert!(error.contains("content-distribution-point"), "{error}"); } +#[test] +fn reordered_scenarios_encode_opposite_order_input_evidence() { + for (fixture, prefix) in [ + (POLICY_MATRIX_FIXTURE, "policy"), + (CONTENT_MATRIX_FIXTURE, "content"), + ] { + let mut matrix = read_json(&corpus_root().join(fixture)); + let first = scenario_slot(&mut matrix, &format!("{prefix}-reordered-input-a")).clone(); + let second = scenario_slot(&mut matrix, &format!("{prefix}-reordered-input-b")).clone(); + + let manifest = |scenario: &Value, label: &str| -> Vec { + scenario["orderedInputEvidence"] + .as_array() + .unwrap_or_else(|| panic!("{fixture}: scenario {label} encodes ordered inputs")) + .iter() + .map(|entry| { + entry + .as_str() + .unwrap_or_else(|| panic!("{fixture}: {label} evidence entry is a string")) + .to_owned() + }) + .collect() + }; + let evidence_a = manifest(&first, "a"); + let evidence_b = manifest(&second, "b"); + + assert!( + evidence_a.len() >= 2, + "{fixture}: too little ordered evidence" + ); + assert_ne!( + evidence_a, evidence_b, + "{fixture}: A and B are not reordered" + ); + let reversed_a = evidence_a.iter().rev().cloned().collect::>(); + assert_eq!( + evidence_b, reversed_a, + "{fixture}: B must replay A's evidence in opposite order" + ); + let mut multiset_a = evidence_a.clone(); + let mut multiset_b = evidence_b.clone(); + multiset_a.sort(); + multiset_b.sort(); + assert_eq!( + multiset_a, multiset_b, + "{fixture}: A and B must carry the same evidence multiset" + ); + for side in ["client:", "server:"] { + assert!( + evidence_a.iter().any(|entry| entry.starts_with(side)), + "{fixture}: ordered evidence must include the {side} side" + ); + } + + for field in [ + "clientFixtureRef", + "serverFixtureRef", + "profileState", + "keyRelation", + "topology", + "timestampProvenance", + "coverage", + "rotation", + "terminalRelation", + ] { + assert_eq!( + first[field], second[field], + "{fixture}: {field} must be identical between A and B" + ); + } + for section in ["expected", "expectedPublicProjection"] { + let serialized_a = serde_json::to_string(&first[section]) + .expect("reordered contract section serializes"); + let serialized_b = serde_json::to_string(&second[section]) + .expect("reordered contract section serializes"); + assert_eq!( + serialized_a, serialized_b, + "{fixture}: {section} must serialize deterministically across A and B" + ); + } + assert_eq!( + first["expected"]["deterministicResultId"], second["expected"]["deterministicResultId"], + "{fixture}: A and B must share one deterministic result id" + ); + } +} + #[test] fn adversarial_neutralized_guard_state_mutations_fail_closed() { let neutralizations: [(&str, &[(&str, &str)]); 9] = [ From f1550646897fd8ae084f2cd1b37c8880b156c13d Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:39:21 -0400 Subject: [PATCH 218/422] fix(sccm): encode ordered evidence in reorder fixtures Give each reordered A/B scenario an orderedInputEvidence manifest of synthetic client and server evidence tokens, with B replaying A's multiset in opposite order, and admit the field in the typed scenario contract. Refs #333 --- .../adversarial-matrix.json | 12 ++++++++++++ .../policy_management_point/adversarial-matrix.json | 12 ++++++++++++ .../tests/sccm_correlation_contract.rs | 2 ++ 3 files changed, 26 insertions(+) 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 index bda006815..52cdd99c9 100644 --- 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 @@ -195,6 +195,12 @@ "guardIds": [ "reordered-input" ], + "orderedInputEvidence": [ + "client:content-location-request", + "client:content-download-summary", + "server:dp-content-availability", + "server:dp-transfer-ack" + ], "clientIssue": "#322", "serverIssue": "#329", "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success", @@ -231,6 +237,12 @@ "guardIds": [ "reordered-input" ], + "orderedInputEvidence": [ + "server:dp-transfer-ack", + "server:dp-content-availability", + "client:content-download-summary", + "client:content-location-request" + ], "clientIssue": "#322", "serverIssue": "#329", "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success", 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 index 44ddb74fc..9892e8271 100644 --- 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 @@ -196,6 +196,12 @@ "guardIds": [ "reordered-input" ], + "orderedInputEvidence": [ + "client:policy-request-assignments", + "client:policy-evaluation-summary", + "server:mp-policy-response", + "server:mp-endpoint-ack" + ], "clientIssue": "#321", "serverIssue": "#328", "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete", @@ -232,6 +238,12 @@ "guardIds": [ "reordered-input" ], + "orderedInputEvidence": [ + "server:mp-endpoint-ack", + "server:mp-policy-response", + "client:policy-evaluation-summary", + "client:policy-request-assignments" + ], "clientIssue": "#321", "serverIssue": "#328", "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete", diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index 94af5ab7b..ca0ee36bc 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -89,6 +89,8 @@ struct ScenarioContract { server_issue: String, client_fixture_ref: String, server_fixture_ref: String, + #[serde(default)] + ordered_input_evidence: Vec, profile_state: ProfileState, key_relation: KeyRelation, topology: TopologyState, From affc63f166668110c8d6525c0b7fff9f057e4977 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:40:11 -0400 Subject: [PATCH 219/422] test(sccm): probe reorder manifest and ref divergence Add mutation probes proving the validator ignores the encoded reorder evidence: B replaying A's order unchanged, a divergent B client fixture ref, an emptied A manifest under a declared reordered-input guard, and an untagged evidence token all pass. Refs #333 --- .../tests/sccm_correlation_contract.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index ca0ee36bc..5f5137cd5 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -945,6 +945,38 @@ fn reordered_scenarios_encode_opposite_order_input_evidence() { } } +#[test] +fn adversarial_reordered_input_mutations_fail_closed() { + let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { + let order_a = + scenario_slot(matrix, "policy-reordered-input-a")["orderedInputEvidence"].clone(); + scenario_slot(matrix, "policy-reordered-input-b")["orderedInputEvidence"] = order_a; + }) + .expect_err("the B case must replay A's evidence in opposite order, not the same order"); + assert!(error.contains("reordered"), "{error}"); + + let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { + scenario_slot(matrix, "policy-reordered-input-b")["clientFixtureRef"] = + Value::String("synthetic:policy-divergent-client".to_owned()); + }) + .expect_err("the A/B pair must reorder one input set, not compare different inputs"); + assert!(error.contains("reordered"), "{error}"); + + let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { + scenario_slot(matrix, "policy-reordered-input-a")["orderedInputEvidence"] = + serde_json::json!([]); + }) + .expect_err("a reordered-input guard without encoded ordered evidence is undemonstrated"); + assert!(error.contains("reordered"), "{error}"); + + let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { + scenario_slot(matrix, "policy-reordered-input-a")["orderedInputEvidence"][0] = + Value::String("policy-request-assignments".to_owned()); + }) + .expect_err("ordered evidence entries must be side-tagged synthetic tokens"); + assert!(error.contains("reordered"), "{error}"); +} + #[test] fn adversarial_neutralized_guard_state_mutations_fail_closed() { let neutralizations: [(&str, &[(&str, &str)]); 9] = [ From 842965df0d57df9f7296684febb167f117e8306e Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:41:11 -0400 Subject: [PATCH 220/422] fix(sccm): enforce reorder manifests in the validator Demonstrate the reordered-input guard by the encoded manifest: entries must be side-tagged synthetic tokens covering both sides, exactly two reordered scenarios must share identical fixture refs and input state, B must replay A's evidence in opposite order, and both must serialize one deterministic expected contract and public projection. Refs #333 --- .../tests/sccm_correlation_contract.rs | 107 +++++++++++++++++- 1 file changed, 103 insertions(+), 4 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index 5f5137cd5..7023cf9fa 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -409,6 +409,35 @@ fn check_scenario(scenario: &ScenarioContract, spec: &MatrixSpec) -> Result<(), .map_err(|error| format!("{scenario_id}: {error}"))?; check_fixture_ref(&scenario.server_fixture_ref, &spec.server_side, "server") .map_err(|error| format!("{scenario_id}: {error}"))?; + if !scenario.ordered_input_evidence.is_empty() { + if scenario.ordered_input_evidence.len() < 2 { + return Err(format!( + "{scenario_id}: reordered input evidence needs at least two entries" + )); + } + for entry in &scenario.ordered_input_evidence { + let side_tagged = entry + .strip_prefix("client:") + .or_else(|| entry.strip_prefix("server:")) + .is_some_and(is_synthetic_slug); + if !side_tagged { + return Err(format!( + "{scenario_id}: reordered evidence entry {entry} is not a side-tagged synthetic token" + )); + } + } + for side in ["client:", "server:"] { + if !scenario + .ordered_input_evidence + .iter() + .any(|entry| entry.starts_with(side)) + { + return Err(format!( + "{scenario_id}: reordered evidence must include the {side} side" + )); + } + } + } if scenario.guard_ids.is_empty() || !is_sorted_unique(&scenario.guard_ids) { return Err(format!( "{scenario_id}: guard IDs must be nonempty, sorted, and unique" @@ -586,10 +615,7 @@ fn guard_demonstrated(guard: &str, scenario: &ScenarioContract) -> bool { } "partial-capture" => scenario.coverage == CoverageState::Partial, "redaction-boundary" => !scenario.private_input_markers.is_empty(), - "reordered-input" => { - scenario.scenario_id.ends_with("-reordered-input-a") - || scenario.scenario_id.ends_with("-reordered-input-b") - } + "reordered-input" => !scenario.ordered_input_evidence.is_empty(), "rotation-split" => scenario.rotation == RotationState::Split, "same-time-no-key" => { scenario.key_relation == KeyRelation::Missing @@ -635,6 +661,79 @@ fn check_matrix_contract(matrix: &ScenarioMatrix, spec: &MatrixSpec) -> Result<( spec.workflow )); } + check_reordered_pair(matrix, spec)?; + Ok(()) +} + +/// The reordered A/B cases must feed one identical input set through two +/// opposite evidence orders and still pin one deterministic contract. +fn check_reordered_pair(matrix: &ScenarioMatrix, spec: &MatrixSpec) -> Result<(), String> { + let reordered = matrix + .scenarios + .iter() + .filter(|scenario| scenario.guard_ids.contains(&"reordered-input".to_owned())) + .collect::>(); + let [first, second] = reordered.as_slice() else { + return Err(format!( + "{}: exactly two reordered-input scenarios are required", + spec.workflow + )); + }; + if first.client_fixture_ref != second.client_fixture_ref + || first.server_fixture_ref != second.server_fixture_ref + { + return Err(format!( + "{}: reordered scenarios must share identical fixture refs", + spec.workflow + )); + } + if first.profile_state != second.profile_state + || first.key_relation != second.key_relation + || first.topology != second.topology + || first.timestamp_provenance != second.timestamp_provenance + || first.coverage != second.coverage + || first.rotation != second.rotation + || first.terminal_relation != second.terminal_relation + { + return Err(format!( + "{}: reordered scenarios must share identical input state", + spec.workflow + )); + } + if second.ordered_input_evidence == first.ordered_input_evidence { + return Err(format!( + "{}: reordered cases must not present the same order twice", + spec.workflow + )); + } + let reversed = first + .ordered_input_evidence + .iter() + .rev() + .cloned() + .collect::>(); + if second.ordered_input_evidence != reversed { + return Err(format!( + "{}: reordered case B must replay case A's evidence in opposite order", + spec.workflow + )); + } + if first.expected != second.expected { + return Err(format!( + "{}: reordered scenarios must pin one expected result", + spec.workflow + )); + } + let projection_a = serde_json::to_string(&first.expected_public_projection) + .expect("expected public projection serializes"); + let projection_b = serde_json::to_string(&second.expected_public_projection) + .expect("expected public projection serializes"); + if projection_a != projection_b { + return Err(format!( + "{}: reordered scenarios must serialize one deterministic public projection", + spec.workflow + )); + } Ok(()) } From fe2819024cecdd9ce6395ab9ed7caa79a8467f45 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:42:14 -0400 Subject: [PATCH 221/422] test(sccm): probe declarative required outputs Add mutation probes proving requiredOutputs are decorative: an arbitraryOutput token in the shared guard matrix, a projection reduced to a bare notCausal outcome, and a client-only scenario requesting no server artifact all pass the suite. Refs #333 --- .../tests/sccm_correlation_contract.rs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index 7023cf9fa..14dc75855 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -1044,6 +1044,46 @@ fn reordered_scenarios_encode_opposite_order_input_evidence() { } } +fn check_mutated_guard_matrix(mutate: impl FnOnce(&mut Value)) -> Result<(), String> { + let mut value = read_json(&corpus_root().join("shared/adversarial-matrix.json")); + mutate(&mut value); + let matrix: GuardMatrix = typed(value)?; + check_guard_matrix(&matrix) +} + +fn guard_slot<'a>(matrix: &'a mut Value, guard_id: &str) -> &'a mut Value { + matrix["guards"] + .as_array_mut() + .expect("guard matrix fixture has a guards array") + .iter_mut() + .find(|guard| guard["guardId"] == guard_id) + .unwrap_or_else(|| panic!("guard {guard_id} exists in the fixture")) +} + +#[test] +fn adversarial_required_output_mutations_fail_closed() { + let error = check_mutated_guard_matrix(|matrix| { + guard_slot(matrix, "invalid-timestamp-offset")["requiredOutputs"] = + serde_json::json!(["arbitraryOutput"]); + }) + .expect_err("required outputs must stay pinned executable obligations, not free strings"); + assert!(error.contains("invalid-timestamp-offset"), "{error}"); + + let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { + scenario_slot(matrix, "policy-invalid-offset")["expectedPublicProjection"] = + serde_json::json!({ "outcome": "notCausal" }); + }) + .expect_err("dropping the ordering obligation from the projection must fail"); + assert!(error.contains("policy-invalid-offset"), "{error}"); + + let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { + scenario_slot(matrix, "policy-client-only")["expected"]["artifactRequests"] = + serde_json::json!(["diagnostic-bundle"]); + }) + .expect_err("a client-only scenario must request server-side artifacts"); + assert!(error.contains("policy-client-only"), "{error}"); +} + #[test] fn adversarial_reordered_input_mutations_fail_closed() { let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { From 8c5aca355ce8431c65301f7f4434f07b42f5040b Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:43:05 -0400 Subject: [PATCH 222/422] fix(sccm): make required outputs executable checks Pin every guard's requiredOutputs to a closed obligation vocabulary and give each token an executable predicate over the scenario's expected results and public projection. The shared matrix rejects unknown tokens and every declared guard's obligations are asserted against its scenario contract. Refs #333 --- .../tests/sccm_correlation_contract.rs | 140 +++++++++++++++++- 1 file changed, 138 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index 14dc75855..4eb8faea7 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -22,6 +22,58 @@ const GUARD_IDS: [&str; 13] = [ "unrelated-terminal-error", "version-mismatch", ]; +/// Closed obligation vocabulary: each guard's requiredOutputs are pinned +/// here and every token has an executable predicate over the scenario's +/// expected contract in `output_obligation_holds`. +const GUARD_REQUIRED_OUTPUTS: [(&str, &[&str]); 13] = [ + ( + "conflicting-exact-key", + &["incompatibilityReason", "sourceLocalResults"], + ), + ( + "incompatible-topology", + &["incompatibilityReason", "sourceLocalResults"], + ), + ( + "invalid-timestamp-offset", + &["orderingUnavailable", "sourceLocalResults"], + ), + ( + "missing-client-counterpart", + &["clientArtifactRequest", "serverLocalResults"], + ), + ( + "missing-server-counterpart", + &["clientLocalResults", "serverArtifactRequest"], + ), + ("partial-capture", &["coverageGap", "sourceLocalResults"]), + ( + "redaction-boundary", + &["publicSafeHandles", "redactedProjection"], + ), + ( + "reordered-input", + &["deterministicSerialization", "sourceLocalResults"], + ), + ("rotation-split", &["coverageGap", "sourceLocalResults"]), + ( + "same-time-no-key", + &["candidateSymptom", "sourceLocalResults"], + ), + ( + "unknown-extraction-profile", + &["profileGap", "sourceLocalResults"], + ), + ( + "unrelated-terminal-error", + &["sourceLocalResults", "unlinkedTerminalEvidence"], + ), + ( + "version-mismatch", + &["incompatibilityReason", "sourceLocalResults"], + ), +]; + const POLICY_SCENARIOS: [&str; 14] = [ "policy-client-only", "policy-conflicting-key", @@ -589,9 +641,89 @@ fn check_scenario(scenario: &ScenarioContract, spec: &MatrixSpec) -> Result<(), )); } } + for guard in &scenario.guard_ids { + let (_, outputs) = GUARD_REQUIRED_OUTPUTS + .iter() + .find(|(pinned_id, _)| pinned_id == guard) + .ok_or_else(|| { + format!("{scenario_id}: guard {guard} has no pinned output obligations") + })?; + for output in *outputs { + if !output_obligation_holds(output, scenario) { + return Err(format!( + "{scenario_id}: guard {guard} obligation {output} is not satisfied by the expected contract" + )); + } + } + } Ok(()) } +/// Evaluates one required-output token against the scenario's expected +/// results and public projection, so obligations are executable +/// constraints instead of declarative strings. +fn output_obligation_holds(output: &str, scenario: &ScenarioContract) -> bool { + let projection = &scenario.expected_public_projection; + match output { + "incompatibilityReason" => { + projection["outcome"] == "incompatible" + && scenario.expected.link_strength_ceiling == "incompatible" + } + "sourceLocalResults" => projection["sourceFindingsPreserved"] == true, + "clientLocalResults" => { + projection["sourceFindingsPreserved"] == true + && scenario.coverage == CoverageState::ClientOnly + } + "serverLocalResults" => { + projection["sourceFindingsPreserved"] == true + && scenario.coverage == CoverageState::ServerOnly + } + "clientArtifactRequest" => { + projection["outcome"] == "counterpartRequested" + && scenario + .expected + .artifact_requests + .iter() + .any(|request| request.starts_with("client-")) + } + "serverArtifactRequest" => { + projection["outcome"] == "counterpartRequested" + && scenario + .expected + .artifact_requests + .iter() + .any(|request| request.starts_with("server-")) + } + "orderingUnavailable" => projection["ordering"] == "unavailable", + "coverageGap" => projection["outcome"] == "coverageGap", + "candidateSymptom" => { + projection["outcome"] == "candidateOnly" + && scenario.expected.link_strength_ceiling == "candidate" + } + "profileGap" => projection["outcome"] == "profileGap", + "unlinkedTerminalEvidence" => scenario + .expected + .reason_codes + .iter() + .any(|code| code == "unrelated-server-terminal"), + "publicSafeHandles" => projection.as_object().is_some_and(|entries| { + entries + .iter() + .filter(|(key, value)| { + key.ends_with("Handle") && value.as_str().is_some_and(is_synthetic_slug) + }) + .count() + >= 2 + }), + "redactedProjection" => !scenario.private_input_markers.is_empty(), + "deterministicSerialization" => { + !scenario.ordered_input_evidence.is_empty() + && !scenario.expected.deterministic_result_id.is_empty() + } + _ => false, + } +} + /// True when the scenario's own input state instantiates the guard's /// adversarial construction. Every declared guard must be demonstrated by /// the inputs, and every demonstrated guard must be declared, so a guard @@ -764,9 +896,13 @@ fn check_guard_matrix(matrix: &GuardMatrix) -> Result<(), String> { if guard.forbidden_confidences != ["high"] { return Err(format!("{guard_id}: guard must forbid high confidence")); } - if guard.required_outputs.is_empty() || !is_sorted_unique(&guard.required_outputs) { + let (_, pinned_outputs) = GUARD_REQUIRED_OUTPUTS + .iter() + .find(|(pinned_id, _)| *pinned_id == guard_id) + .ok_or_else(|| format!("{guard_id}: guard has no pinned output obligations"))?; + if guard.required_outputs != *pinned_outputs { return Err(format!( - "{guard_id}: required outputs must be nonempty, sorted, and unique" + "{guard_id}: required outputs must stay the pinned executable obligations" )); } } From 72c9798caa85f602879d2ad99220736f1e407ec2 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:43:32 -0400 Subject: [PATCH 223/422] docs(sccm): document bound refs and reorder evidence Keep the correlation fixture README honest about the hardened contract: guard demonstration, executable output obligations, side-bound fixture refs, the #329 pending-only server side, and the opposite-order evidence manifests. Refs #333 --- .../tests/fixtures/sccm/correlation/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md index 7b703669e..55542d740 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md @@ -18,13 +18,13 @@ The shared matrix defines thirteen mandatory guards. Each first-pair matrix inst - private-marker redaction; - reordered input. -Every adversarial expected result forbids `exactCorroborated`, caps confidence below High, preserves source findings, and uses stable reason/request/result identifiers. Reordered input A/B cases pin identical expected public projections and result contracts. +Every adversarial expected result forbids `exactCorroborated`, caps confidence below High, preserves source findings, and uses stable reason/request/result identifiers. Every declared guard must be demonstrated by the scenario's own input state, and each guard's `requiredOutputs` token is an executable predicate checked against the scenario's expected contract. Reordered input A/B cases encode one `orderedInputEvidence` manifest of side-tagged synthetic tokens, with B replaying A's multiset in opposite order, and pin identical expected public projections and result contracts. -Fixture references have explicit status: +Fixture references have explicit status and are bound to their pair side: -- `repo:` references point to already merged synthetic upstream fixture directories; -- `issue:#329:` references name pending DP scenarios without pretending they exist on the program baseline; -- `synthetic:` references describe future pair-local sanitized inputs; -- `absent` is an intentional missing counterpart, never proof of failure. +- `repo:` references point to already merged synthetic upstream fixture directories under the citing side's own corpus prefix; +- `issue:#329:` references name pending DP scenarios without pretending they exist on the program baseline; the content server side may use nothing else (or `absent`) until #329 is independently accepted; +- `synthetic:` references describe future pair-local sanitized inputs and are only valid on merged sides; +- `absent` is an intentional missing counterpart, must agree with the declared one-sided coverage, and is never proof of failure. No raw Windows path, live hostname, user, tenant, token, or database data belongs here. The only identity-shaped values are reserved synthetic private markers used to prove that expected public projections omit them. From 5ec1f5d048b67d23c7a5ee43194e6029e9ea35f1 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:44:36 -0400 Subject: [PATCH 224/422] style(sccm): drop redaction check subsumed by guards The marker/guard coupling is now enforced by the redaction boundary demonstration biconditional, and clippy rejects the leftover double-negated form. Refs #333 --- .../tests/sccm_correlation_contract.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index 4eb8faea7..fb9b85785 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -568,16 +568,6 @@ fn check_scenario(scenario: &ScenarioContract, spec: &MatrixSpec) -> Result<(), )); } } - if scenario.private_input_markers.is_empty() - != !scenario - .guard_ids - .contains(&"redaction-boundary".to_owned()) - { - return Err(format!( - "{scenario_id}: private markers and the redaction guard must be declared together" - )); - } - if scenario.profile_state != ProfileState::Validated && !scenario .guard_ids From cc7d2b532b7f92d1b19e3f1115a98e1718e49d1a Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:47:33 -0400 Subject: [PATCH 225/422] docs(sccm): sharpen the #329 pending distinction The skeleton merge landed the #329 DP preparation corpus (PR 374), so 'without pretending they exist on the program baseline' no longer described reality. The Pending state itself is still correct: the corpus is merged, but the #329 public fact interface is not independently accepted and no production reducer exists for the #322/#329 pair, so the content server side stays issue-marked and the registry blocker stands. Refs #333 --- .../tests/fixtures/sccm/correlation/README.md | 2 +- .../tests/sccm_correlation_contract.rs | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md index 55542d740..790ce25bf 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md @@ -23,7 +23,7 @@ Every adversarial expected result forbids `exactCorroborated`, caps confidence b Fixture references have explicit status and are bound to their pair side: - `repo:` references point to already merged synthetic upstream fixture directories under the citing side's own corpus prefix; -- `issue:#329:` references name pending DP scenarios without pretending they exist on the program baseline; the content server side may use nothing else (or `absent`) until #329 is independently accepted; +- `issue:#329:` references mark DP scenarios whose public fact interface is not independently accepted; the #329 preparation corpus is merged on the program baseline, but until its fact interface is accepted (no production reducer exists for the #322/#329 pair) the content server side may use nothing else (or `absent`); - `synthetic:` references describe future pair-local sanitized inputs and are only valid on merged sides; - `absent` is an intentional missing counterpart, must agree with the declared one-sided coverage, and is never proof of failure. diff --git a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index fb9b85785..5406d5f29 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -259,8 +259,9 @@ enum PairState { /// /// `Merged` sides may only cite already merged synthetic corpus directories /// under their own side prefix (or pair-local `synthetic:` inputs). `Pending` -/// sides have no accepted corpus on the program baseline, so they must stay -/// honestly marked with `issue:` refs until the upstream issue is accepted. +/// sides may have a merged preparation corpus, but their public fact +/// interface is not independently accepted, so they must stay honestly +/// marked with `issue:` refs until that acceptance lands. enum SideCorpus { Merged { repo_prefix: &'static str }, Pending { issue: &'static str }, @@ -296,8 +297,10 @@ const CONTENT_SPEC: MatrixSpec = MatrixSpec { client_side: SideCorpus::Merged { repo_prefix: "crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/", }, - // #329 DP evidence is not independently accepted yet, so the server side - // may only name pending scenarios and must never cite a merged corpus. + // #329's preparation corpus is merged, but its public fact interface is + // not independently accepted (no production reducer exists for the + // #322/#329 pair), so the server side stays issue-marked and must not + // present the corpus as accepted upstream facts. server_side: SideCorpus::Pending { issue: "#329" }, }; From af1214d6db20852a8f040df9f172df0f9f3dc05a Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:53:47 -0400 Subject: [PATCH 226/422] test(sccm): probe sentence-final dotted hostname leaks Before this test the closed narrative grammar trimmed commas, semicolons, and bracket punctuation from token edges but not the sentence-final period, so a dotted hostname ending a sentence kept a trailing empty label and defeated the host-shape rejection. lab-client-01.corp.local. was accepted in both a public observation claim and a next-artifact request reason while the same hostname mid-sentence was rejected. The two new probes fail today and pin the review requirement. Refs #326 --- ...sccm_client_management_fixture_contract.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index a7a622f46..59cfa3486 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -3257,3 +3257,41 @@ fn stale_mixed_selection_state_mutations_fail_closed() { "removing the only unknown-version artifact must flip the derived selection state" ); } + +#[test] +fn sentence_final_hostname_privacy_mutations_fail_closed() { + let mut accepted = Vec::new(); + + let (observed_root, observed_manifest, mut hostname_claim) = + load_contract("software-center-observed"); + hostname_claim["sourceLocalObservations"][0]["claim"] = Value::String( + "Observed records remain under lab-client-01.corp.local.".to_owned(), + ); + if mutation_was_accepted( + "software-center-observed", + &observed_root, + &observed_manifest, + &hostname_claim, + ) { + accepted.push("sentence-final dotted hostname in a public observation claim"); + } + + let (deferred_root, deferred_manifest, mut hostname_reason) = + load_contract("notification-deferred"); + hostname_reason["transactions"][0]["nextArtifact"]["reason"] = Value::String( + "Collect the bounded continuation from lab-client-01.corp.local.".to_owned(), + ); + if mutation_was_accepted( + "notification-deferred", + &deferred_root, + &deferred_manifest, + &hostname_reason, + ) { + accepted.push("sentence-final dotted hostname in a next-artifact request reason"); + } + + assert!( + accepted.is_empty(), + "sentence-final hostname mutations were accepted: {accepted:?}" + ); +} From a9fc487e695b1409aa225161b3afcd8901f4ef8f Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:54:05 -0400 Subject: [PATCH 227/422] fix(sccm): trim sentence-final periods before host test Before this change the token trim list for the host-shape rejection omitted the period, so a dotted hostname at sentence end carried a trailing empty label and slipped past the closed narrative grammar on claims and next-artifact reasons. Trim periods from token edges before the dotted-label test so sentence-final hostnames fail closed while mid-sentence rejection and legitimate narratives are unchanged. Refs #326 --- .../tests/sccm_client_management_fixture_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 59cfa3486..59ad0d285 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -387,7 +387,7 @@ fn public_free_text_is_safe(value: &str) -> bool { let token = token.trim_matches(|character: char| { matches!( character, - ',' | ';' | ':' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '"' | '\'' + '.' | ',' | ';' | ':' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '"' | '\'' ) }); let mut labels = token.split('.'); From e3623c5eb9a8db0d8f8431e5711affb38892373c Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:54:30 -0400 Subject: [PATCH 228/422] test(sccm): probe SID-shaped public identifiers Before this test the closed public identifier grammar accepted any lowercase alphanumeric hyphen-separated string, so a synthetic machine SID s-1-5-21-... passed as both an observationId and a transactionId even though the free-text grammar already rejects the s-1-5- prefix. The two new probes fail today and pin the review requirement that identifier surfaces reject SID-shaped values. Refs #326 --- ...sccm_client_management_fixture_contract.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 59ad0d285..5c482e256 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -3295,3 +3295,39 @@ fn sentence_final_hostname_privacy_mutations_fail_closed() { "sentence-final hostname mutations were accepted: {accepted:?}" ); } + +#[test] +fn sid_shaped_public_identifier_mutations_fail_closed() { + let synthetic_sid = "s-1-5-21-1004336348-1177238915-682003330-512"; + let mut accepted = Vec::new(); + + let (observed_root, observed_manifest, mut sid_observation_id) = + load_contract("software-center-observed"); + sid_observation_id["sourceLocalObservations"][0]["observationId"] = + Value::String(synthetic_sid.to_owned()); + if mutation_was_accepted( + "software-center-observed", + &observed_root, + &observed_manifest, + &sid_observation_id, + ) { + accepted.push("SID-shaped public observation id"); + } + + let (script_root, script_manifest, mut sid_transaction_id) = load_contract("script-success"); + sid_transaction_id["transactions"][0]["transactionId"] = + Value::String(synthetic_sid.to_owned()); + if mutation_was_accepted( + "script-success", + &script_root, + &script_manifest, + &sid_transaction_id, + ) { + accepted.push("SID-shaped public transaction id"); + } + + assert!( + accepted.is_empty(), + "SID-shaped public identifier mutations were accepted: {accepted:?}" + ); +} From 325b29fec913de7cc7bfe5efd6a85153be08e325 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:54:45 -0400 Subject: [PATCH 229/422] fix(sccm): reject SID-shaped public identifiers Before this change public_identifier_is_safe admitted any lowercase alphanumeric hyphen-separated string, so machine-SID values passed as observation and transaction ids. Require a leading lowercase letter and reject the s-1-5- prefix, matching the free-text grammar. All 19 shipped observation and transaction identifiers already begin with a lowercase letter, so no fixture changes are needed. Refs #326 --- .../tests/sccm_client_management_fixture_contract.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 5c482e256..f994bf5c0 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -355,6 +355,8 @@ fn source_version_matches_selected_profile(value: &str) -> bool { 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-") && value.split('-').all(|segment| { !segment.is_empty() && segment From 59a689694bb5bd4db6c9333f294da1301aee86b8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:55:45 -0400 Subject: [PATCH 230/422] fix(sccm): validate citation ranges before uniqueness walk Before this change the transaction path ran the citation-uniqueness expansion before evidence_records validated line ranges, so an absurd endLine iterated an unbounded range instead of failing fast; the ownership path already validated ranges first. Reorder the transaction checks to match ownership and add a fast-fail regression for a u64::MAX endLine on both surfaces. Also fold in rustfmt output for the hostname probe test. Refs #326 --- ...sccm_client_management_fixture_contract.rs | 49 ++++++++++++++++--- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index f994bf5c0..aeef70b6b 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -1557,12 +1557,12 @@ fn validate_contract( "{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" )); } - let records = evidence_records(scenario_root, &artifacts_by_id, &transaction["evidence"])?; if records.is_empty() { return Err(format!("{transaction_id} has no cited evidence")); } @@ -3266,9 +3266,8 @@ fn sentence_final_hostname_privacy_mutations_fail_closed() { let (observed_root, observed_manifest, mut hostname_claim) = load_contract("software-center-observed"); - hostname_claim["sourceLocalObservations"][0]["claim"] = Value::String( - "Observed records remain under lab-client-01.corp.local.".to_owned(), - ); + hostname_claim["sourceLocalObservations"][0]["claim"] = + Value::String("Observed records remain under lab-client-01.corp.local.".to_owned()); if mutation_was_accepted( "software-center-observed", &observed_root, @@ -3280,9 +3279,8 @@ fn sentence_final_hostname_privacy_mutations_fail_closed() { let (deferred_root, deferred_manifest, mut hostname_reason) = load_contract("notification-deferred"); - hostname_reason["transactions"][0]["nextArtifact"]["reason"] = Value::String( - "Collect the bounded continuation from lab-client-01.corp.local.".to_owned(), - ); + hostname_reason["transactions"][0]["nextArtifact"]["reason"] = + Value::String("Collect the bounded continuation from lab-client-01.corp.local.".to_owned()); if mutation_was_accepted( "notification-deferred", &deferred_root, @@ -3333,3 +3331,40 @@ fn sid_shaped_public_identifier_mutations_fail_closed() { "SID-shaped public identifier mutations were accepted: {accepted:?}" ); } + +#[test] +fn absurd_citation_ranges_fail_fast_before_expansion() { + let (script_root, script_manifest, script_expected) = load_contract("script-success"); + + let mut absurd_transaction_range = script_expected.clone(); + absurd_transaction_range["transactions"][0]["evidence"] = serde_json::json!([ + { "artifactId": "script-success-current", "startLine": 1, "endLine": u64::MAX } + ]); + let error = validate_contract( + "script-success", + &script_root, + &script_manifest, + &absurd_transaction_range, + ) + .expect_err("absurd transaction endLine must be rejected"); + assert!( + error.contains("evidence line range"), + "absurd transaction endLine must fail range validation, got: {error}" + ); + + let mut absurd_ownership_range = script_expected; + absurd_ownership_range["ownership"]["evidence"] = serde_json::json!([ + { "artifactId": "script-success-owner", "startLine": 1, "endLine": u64::MAX } + ]); + let error = validate_contract( + "script-success", + &script_root, + &script_manifest, + &absurd_ownership_range, + ) + .expect_err("absurd ownership endLine must be rejected"); + assert!( + error.contains("evidence line range"), + "absurd ownership endLine must fail range validation, got: {error}" + ); +} From ef0f431304d366ab83d398ea933fd3522459cae8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 16:59:24 -0400 Subject: [PATCH 231/422] test(sccm): bind MP analysis to flipped mpcontrol row The merged skeleton (#396) models mpcontrol.log as produced by the site-server MP control workflow about the Management Point, not by the MP role itself. Freeze that contract in the reducer-source test, flip the iis-supplemental fixture to the site-server producer role, and add failing tests: a site-server mpcontrol capture must not shape MP-produced policy coverage states, and an mpcontrol source claiming MP production must fail closed as rejected evidence. Refs #328 --- .../iis-supplemental/expected.json | 2 +- .../iis-supplemental/manifest.json | 3 +- .../tests/sccm_server_management_point.rs | 131 +++++++++++++++--- 3 files changed, 116 insertions(+), 20 deletions(-) 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 index 7403d5e6b..37d4e954c 100644 --- 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 @@ -16,7 +16,7 @@ ], "artifactProvenance": [ {"artifactId":"mp-iis-auth-current","captureState":"captured","role":"managementPoint","producer":"MP_GetAuth","pathFingerprint":"synthetic:iis-auth","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST.0000","collectedUtc":"2026-07-30T06:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, - {"artifactId":"mp-iis-control-current","captureState":"captured","role":"managementPoint","producer":"SMS_MP_CONTROL_MANAGER","pathFingerprint":"synthetic:iis-role-context","pathProvenance":"configuredSynthetic","sourceVersion":"5.00.TEST.0000","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.0000","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.0000","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.0000","collectedUtc":"2026-07-30T06:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} 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 index 0d56c9f64..4bf80bb42 100644 --- 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 @@ -96,7 +96,8 @@ { "artifactId": "mp-iis-control-current", "designOnlyCatalog": {"entryId":"server-mp-policy","groupMemberships":["server-mp-policy"]}, - "role": "managementPoint", + "role": "siteServer", + "workflowSubject": {"role":"managementPoint"}, "producer": "SMS_MP_CONTROL_MANAGER", "sourceKind": "ccmLog", "captureState": "captured", diff --git a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs index 37cf6ccb2..0082bdfab 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -3,8 +3,8 @@ use std::fs; use std::path::{Path, PathBuf}; use cmtraceopen_parser::sccm::server::windows::{ - analyze_management_point, SccmManagementPointBundle, SccmManagementPointSource, - SccmManagementPointTopology, + analyze_management_point, declared_server_source_catalog, SccmManagementPointBundle, + SccmManagementPointSource, SccmManagementPointTopology, }; use cmtraceopen_parser::sccm::{ declared_source_catalog, normalize_ccm_artifact, SccmArtifact, SccmArtifactFamily, @@ -127,16 +127,17 @@ fn load_bundle(scenario: &str) -> SccmManagementPointBundle { let mut sources = Vec::new(); let mut evidence = Vec::new(); for source in manifest.artifacts { - assert_eq!( - source.role, "managementPoint", - "MP fixtures must preserve their server role" - ); + 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: SccmRole::ManagementPoint, + role: producer_role, configmgr_version: source.source_version, collected_at_utc: source.collected_utc, rotation: rotation(&source.rotation), @@ -639,7 +640,7 @@ fn failed_counterpart_handoff_cites_the_decided_terminal_failure() { #[test] fn management_point_catalog_declares_every_reducer_source() { - let sources = declared_source_catalog() + let mp_produced = declared_source_catalog() .into_iter() .filter(|source| { source.role == SccmRole::ManagementPoint @@ -647,21 +648,44 @@ fn management_point_catalog_declares_every_reducer_source() { }) .map(|source| (source.basename, source.logical_name)) .collect::>(); - - for expected in [ + let expected_mp_produced = [ ("MP_CliReg.log", "mpCliReg"), ("MP_GetAuth.log", "mpGetAuth"), ("MP_GetPolicy.log", "mpGetPolicy"), ("MP_Location.log", "mpLocation"), ("MP_RegistrationManager.log", "mpRegistrationManager"), - ("mpcontrol.log", "mpcontrol"), - ] { - let expected = (expected.0.to_owned(), expected.1.to_owned()); - assert!( - sources.contains(&expected), - "missing MP source {expected:?}" - ); - } + ] + .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 { @@ -1438,3 +1462,74 @@ fn management_point_transaction_citations_do_not_span_rejected_records() { "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); + assert!( + analysis["sourceLocalObservations"] + .as_array() + .expect("source-local observations") + .iter() + .any(|observation| { + observation["classification"] == "lowConfidenceSymptom" + && observation["correlationEligible"] == false + && observation["evidence"] + .as_array() + .expect("observation evidence") + .iter() + .any(|reference| reference["artifactId"] == "mp-iis-control-current") + }), + "an mpcontrol source claiming MP production is a contract violation \ + and must surface as rejected evidence, not silent supplemental input" + ); +} From 4a2d9269b28bdc5a7c41c26ff6f74c28d111b2b9 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 17:00:21 -0400 Subject: [PATCH 232/422] fix(sccm): treat mpcontrol as site-server subject evidence Key the subject-scoped mpcontrol supplemental check on the shared catalog identity (site-server producer role, ManagementPoint family) instead of the hardcoded producer string, and exclude supplemental sources from MP-produced coverage states so a site-server mpcontrol capture can no longer masquerade as MP policy coverage. An mpcontrol source claiming MP production now fails closed as rejected evidence. Refs #328 --- .../sccm/server/windows/management_point.rs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs index 171453a6b..68857cbd1 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs @@ -976,14 +976,22 @@ fn source_is_admitted(source: &SccmManagementPointSource) -> bool { && 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 { - source.source_group == MP_IIS_GROUP - || (source.source_group == MP_POLICY_GROUP - && source.producer == "SMS_MP_CONTROL_MANAGER" - && source - .artifact - .display_name - .eq_ignore_ascii_case("mpcontrol.log")) + 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> { @@ -1564,7 +1572,7 @@ fn coverage_for_group(bundle: &SccmManagementPointBundle, group: &str) -> SccmCo let states = bundle .sources .iter() - .filter(|source| source.source_group == group) + .filter(|source| source.source_group == group && !is_supplemental_source(source)) .map(|source| { if source.artifact.coverage == SccmCoverageState::Captured && (source.fragment_complete != Some(true) From dc87d775df99b32e3f1c768ef029d3cdfd0183a9 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 17:08:40 -0400 Subject: [PATCH 233/422] test(sccm): probe network-identifier shaped tokens Before this test the host-shape rejection required an alphabetic final label, so a sentence-final dotted quad 10.20.30.40. passed both public free-text surfaces and a hyphen-joined final label such as lab-client-01.corp.local-side. evaded the hostname check. The three new probes fail today and pin the class requirement that any multi-dot token is network-identifier shaped regardless of final label charset. Refs #326 --- ...sccm_client_management_fixture_contract.rs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index aeef70b6b..892c00760 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -3368,3 +3368,53 @@ fn absurd_citation_ranges_fail_fast_before_expansion() { "absurd ownership endLine must fail range validation, got: {error}" ); } + +#[test] +fn network_identifier_token_mutations_fail_closed() { + let mut accepted = Vec::new(); + + let (observed_root, observed_manifest, observed_expected) = + load_contract("software-center-observed"); + + let mut dotted_quad_claim = observed_expected.clone(); + dotted_quad_claim["sourceLocalObservations"][0]["claim"] = + Value::String("Observed records remain under 10.20.30.40.".to_owned()); + if mutation_was_accepted( + "software-center-observed", + &observed_root, + &observed_manifest, + &dotted_quad_claim, + ) { + accepted.push("sentence-final dotted quad in a public observation claim"); + } + + let mut hyphen_label_host_claim = observed_expected; + hyphen_label_host_claim["sourceLocalObservations"][0]["claim"] = + Value::String("Observed records remain under lab-client-01.corp.local-side.".to_owned()); + if mutation_was_accepted( + "software-center-observed", + &observed_root, + &observed_manifest, + &hyphen_label_host_claim, + ) { + accepted.push("hyphen-joined final label hostname in a public observation claim"); + } + + let (deferred_root, deferred_manifest, mut dotted_quad_reason) = + load_contract("notification-deferred"); + dotted_quad_reason["transactions"][0]["nextArtifact"]["reason"] = + Value::String("Collect the bounded continuation from 10.20.30.40.".to_owned()); + if mutation_was_accepted( + "notification-deferred", + &deferred_root, + &deferred_manifest, + &dotted_quad_reason, + ) { + accepted.push("sentence-final dotted quad in a next-artifact request reason"); + } + + assert!( + accepted.is_empty(), + "network-identifier token mutations were accepted: {accepted:?}" + ); +} From 8cd55012f5d01247443982923695aefc3abf4114 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 17:09:08 -0400 Subject: [PATCH 234/422] fix(sccm): reject any multi-dot network-shaped token Before this change the host-shape rejection keyed on an alphabetic final label, so dotted quads and hosts ending in hyphen-joined labels slipped through while ordinary hostnames were caught. Close the class: after edge trimming, any token with two or more dots and all labels non-empty is network-identifier shaped and fails regardless of label charset; single-dot tokens keep the alphabetic-suffix host test. No shipped claim or reason contains a multi-dot token, so all fixtures still validate. Refs #326 --- ...sccm_client_management_fixture_contract.rs | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 892c00760..ddcc6036d 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -392,22 +392,18 @@ fn public_free_text_is_safe(value: &str) -> bool { '.' | ',' | ';' | ':' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '"' | '\'' ) }); - let mut labels = token.split('.'); - let Some(first) = labels.next() else { + let labels = token.split('.').collect::>(); + if labels.iter().any(|label| label.is_empty()) { return false; - }; - let remaining = labels.collect::>(); - !first.is_empty() - && !remaining.is_empty() - && remaining.iter().all(|label| { - !label.is_empty() - && label - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') - }) - && remaining.last().is_some_and(|suffix| { - suffix.len() >= 2 && suffix.bytes().all(|b| b.is_ascii_alphabetic()) - }) + } + // 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()) }) } From e8b3a0078b7896b62921c488670505db0acfec10 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 17:09:35 -0400 Subject: [PATCH 235/422] test(sccm): probe non-NT SID authority variants Before this test SID rejection was a literal s-1-5- substring in free text and prefix in identifiers, so Entra account SIDs (s-1-12-1-...) and capability SIDs (s-1-15-3-...) passed as observation ids, transaction ids, claims, and next-artifact reasons. The four new probes fail today and pin the class requirement of a generic SID shape rule; boundary controls keep bare s-1-5 (no subauthority run) acceptable on both surfaces. Refs #326 --- ...sccm_client_management_fixture_contract.rs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index ddcc6036d..dde79ab52 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -3414,3 +3414,78 @@ fn network_identifier_token_mutations_fail_closed() { "network-identifier token mutations were accepted: {accepted:?}" ); } + +#[test] +fn sid_authority_variant_mutations_fail_closed() { + let entra_sid = "s-1-12-1-1004336348-1177238915-682003330-512"; + let capability_sid = "s-1-15-3-1024-2044478260"; + let mut accepted = Vec::new(); + + let (observed_root, observed_manifest, observed_expected) = + load_contract("software-center-observed"); + + let mut sid_observation_id = observed_expected.clone(); + sid_observation_id["sourceLocalObservations"][0]["observationId"] = + Value::String(entra_sid.to_owned()); + if mutation_was_accepted( + "software-center-observed", + &observed_root, + &observed_manifest, + &sid_observation_id, + ) { + accepted.push("Entra-authority SID as a public observation id"); + } + + let mut sid_claim = observed_expected; + sid_claim["sourceLocalObservations"][0]["claim"] = + Value::String(format!("Observed records remain under {entra_sid} markers.")); + if mutation_was_accepted( + "software-center-observed", + &observed_root, + &observed_manifest, + &sid_claim, + ) { + accepted.push("Entra-authority SID inside a public observation claim"); + } + + let (script_root, script_manifest, mut sid_transaction_id) = load_contract("script-success"); + sid_transaction_id["transactions"][0]["transactionId"] = + Value::String(capability_sid.to_owned()); + if mutation_was_accepted( + "script-success", + &script_root, + &script_manifest, + &sid_transaction_id, + ) { + accepted.push("capability-authority SID as a public transaction id"); + } + + let (deferred_root, deferred_manifest, mut sid_reason) = + load_contract("notification-deferred"); + sid_reason["transactions"][0]["nextArtifact"]["reason"] = Value::String(format!( + "Collect the bounded continuation for {capability_sid} records." + )); + if mutation_was_accepted( + "notification-deferred", + &deferred_root, + &deferred_manifest, + &sid_reason, + ) { + accepted.push("capability-authority SID inside a next-artifact request reason"); + } + + assert!( + accepted.is_empty(), + "SID authority variant mutations were accepted: {accepted:?}" + ); + + // Boundary controls: bare s-1-5 carries no subauthority run and is not a SID. + assert!( + public_free_text_is_safe("Access remained denied for the s-1-5 authority marker."), + "bare s-1-5 free text must stay accepted" + ); + assert!( + public_identifier_is_safe("software-center-observed-s-1-5"), + "bare s-1-5 identifier suffix must stay accepted" + ); +} From 89d538b87b4dcfa516971cf53372e9f907d2b54c Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 17:10:16 -0400 Subject: [PATCH 236/422] fix(sccm): reject generic SID-shaped runs on public surfaces Before this change SID rejection was spelled as a literal s-1-5- substring or prefix, so SIDs under other authorities (Entra s-1-12-, capability s-1-15-, and any future authority) passed identifier and free-text validation. Close the class with one helper that flags any s-1- run followed by two or more all-numeric dash-joined segments, applied to both public_identifier_is_safe and the free-text guard. Bare s-1-5 has no subauthority run and stays acceptable. Also fold in rustfmt output for the probe test. Refs #326 --- ...sccm_client_management_fixture_contract.rs | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index dde79ab52..690da3332 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -352,11 +352,25 @@ fn source_version_matches_selected_profile(value: &str) -> bool { .is_some_and(|suffix| suffix.len() == 4 && suffix.bytes().all(|byte| byte.is_ascii_digit())) } +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 @@ -381,7 +395,8 @@ fn public_free_text_is_safe(value: &str) -> bool { return false; } - if value.to_ascii_lowercase().contains("s-1-5-") { + let lower = value.to_ascii_lowercase(); + if lower.contains("s-1-5-") || contains_sid_shaped_run(&lower) { return false; } @@ -3437,8 +3452,9 @@ fn sid_authority_variant_mutations_fail_closed() { } let mut sid_claim = observed_expected; - sid_claim["sourceLocalObservations"][0]["claim"] = - Value::String(format!("Observed records remain under {entra_sid} markers.")); + sid_claim["sourceLocalObservations"][0]["claim"] = Value::String(format!( + "Observed records remain under {entra_sid} markers." + )); if mutation_was_accepted( "software-center-observed", &observed_root, @@ -3460,8 +3476,7 @@ fn sid_authority_variant_mutations_fail_closed() { accepted.push("capability-authority SID as a public transaction id"); } - let (deferred_root, deferred_manifest, mut sid_reason) = - load_contract("notification-deferred"); + let (deferred_root, deferred_manifest, mut sid_reason) = load_contract("notification-deferred"); sid_reason["transactions"][0]["nextArtifact"]["reason"] = Value::String(format!( "Collect the bounded continuation for {capability_sid} records." )); From c7b1e8e4d1a4a2ff599beb4846f0dd8e086b5474 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 17:12:34 -0400 Subject: [PATCH 237/422] test(sccm): pin bracket prefixes and aliased observations Before this change complete_field_tokens treated the bracket characters as unconditional token boundaries, but inside a CCM log body a bare bracket is a legal value character, so a declared key that is a strict prefix of the recorded value was admitted whenever the next character was a bracket. The source-local finding path also trusted sourceLocalObservations without checking that the observed records were not already keyed transaction evidence, so aliasing observations could launder a finding that pools two unrelated exact runs. Add two failing mutation tests: one suffixes recorded values with bracket-delimited junk and proves the truncated keys are accepted, the other aliases the two unrelated-runs transaction records as candidate observations and proves the pooled finding validates. Both must fail closed. Refs #324 --- ...m_client_task_sequence_fixture_contract.rs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) 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 index 308fecb53..1a5426a5e 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -3022,3 +3022,79 @@ fn finding_evidence_cannot_mix_unrelated_exact_runs() { .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}"); + } +} + +#[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}"); +} From 495a36ae6e5b5df7dabbaa563b875b526e78b1fc Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 17:14:12 -0400 Subject: [PATCH 238/422] fix(sccm): bound tokens by body edges, disjoint observations Before this change complete_field_tokens split cited records on brackets as well as whitespace, so a bare bracket inside a CCM body truncated the recorded value and a declared key that was a strict prefix of it was admitted. Source-local observations were also trusted blindly, so observations aliasing keyed transaction records could launder a finding that pools two unrelated runs. Tokenize only the record body, delimited by the LOG prefix and the full ]LOG]!> terminator, and split it on whitespace alone: whitespace and the body edges are the only token boundaries, so a value bounded by the genuine terminator still matches while bracket-suffixed junk does not. Reject any sourceLocalObservation whose citation equals any transaction evidence, ordering, or terminal reference; observations exist only for records that could not be keyed, and no shipped scenario aliases. Also sort both sides of the joinFields comparison so declaration order cannot fail a correct set, mirroring forbiddenJoinFields. Refs #324 --- ...m_client_task_sequence_fixture_contract.rs | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) 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 index 1a5426a5e..b2a03f3b4 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -412,12 +412,18 @@ fn smsts_log_paths(contents: &str) -> BTreeSet { } fn complete_field_tokens(record_text: &str) -> BTreeSet<&str> { - record_text - .split(|character: char| { - character.is_whitespace() || matches!(character, '[' | ']' | '<' | '>') + // 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. + let body = record_text + .strip_prefix("") + .map(|terminator| &after_prefix[..terminator]) }) - .filter(|token| !token.is_empty()) - .collect() + .unwrap_or(record_text); + body.split_whitespace().collect() } fn path_class_for_sanitized_path(path: &str) -> Option<&'static str> { @@ -1181,7 +1187,8 @@ fn validate_contract( let declared_scope = expected["correlationBoundary"]["scope"] .as_str() .ok_or_else(|| format!("{scenario}: correlation scope is not a string"))?; - let declared_join_fields = string_array(&expected["correlationBoundary"]["joinFields"])?; + 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(); @@ -1215,7 +1222,9 @@ fn validate_contract( "{scenario}: correlation scope is not an enforced client-side scope" )); } - if declared_join_fields != EXACT_KEY_JOIN_FIELDS.map(str::to_owned) { + 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" )); @@ -1666,6 +1675,22 @@ fn validate_contract( if observation["evidence"]["artifactId"] != artifact_id { return Err(format!("{observation_id}: citation changed artifact")); } + let cites_keyed_transaction_evidence = transactions.iter().any(|transaction| { + transaction["evidence"] + .as_array() + .is_some_and(|transaction_evidence| { + transaction_evidence + .iter() + .any(|transaction_ref| transaction_ref == &observation["evidence"]) + }) + || transaction["orderingEvidence"] == observation["evidence"] + || 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"])?; } From 5862eb5e22ce42901441b861bafd4e93cfa3d4b0 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 17:19:37 -0400 Subject: [PATCH 239/422] test(sccm): request the rejected record's owning group Rejected MP records currently always ask for MP_GetAuth.log even when the rejected record lives in the server-mp-policy group. Freeze the corrected contract: remediation hints must request the owning group of the rejected references, one group per observation, across all three rejection flavors. The unrelated-client-like-key fixture flips to the policy group (its only captured source), which also unmasks the honest absent-auth coverage observation, and rejected observation ids become group-qualified so mixed-group rejections stay unique. Refs #328 --- .../rotation-boundary/expected.json | 4 +- .../unrelated-client-like-key/expected.json | 6 +- .../tests/sccm_server_management_point.rs | 100 +++++++++++++++--- 3 files changed, 90 insertions(+), 20 deletions(-) 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 index d987d83d8..25758d14f 100644 --- 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 @@ -21,12 +21,12 @@ "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","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}]} + {"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:mp-rotation-malformed","subjectId":"observation:rotation-malformed","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}]} + {"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"]}, 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 index b13d29e2d..993afbdd2 100644 --- 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 @@ -20,11 +20,13 @@ "primaryTransactionId": null, "transactions": [], "sourceLocalObservations": [ - {"observationId":"observation:unrelated-client-like-key","key":null,"keyConfidence":"none","classification":"incompatibleKey","phase":"resolveLocationOrPolicy","lastSuccessfulPhase":null,"confidence":"low","confidenceCeiling":"low","correlationEligible":false,"borrowedKeys":false,"nextArtifact":{"logicalArtifactId":"server-mp-auth","reason":"Capture a bounded authentication-family source with the exact versioned request key."},"evidence":[{"artifactId":"mp-unrelated-policy-current","startLine":1,"endLine":1}]} + {"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-unrelated-client-like-key","subjectId":"observation:unrelated-client-like-key","class":"lowConfidenceSymptom","phase":"resolveLocationOrPolicy","lastSuccessfulPhase":null,"confidence":"low","confidenceCeiling":"low","nextArtifact":{"logicalArtifactId":"server-mp-auth","reason":"Capture a bounded authentication-family source with the exact versioned request key."},"evidence":[{"artifactId":"mp-unrelated-policy-current","startLine":1,"endLine":1}]} + {"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, diff --git a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs index 0082bdfab..10aeb6132 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -1515,21 +1515,89 @@ fn mp_produced_mpcontrol_claims_fail_closed_as_rejected_evidence() { } let analysis = analysis_value(&bundle); - assert!( - analysis["sourceLocalObservations"] - .as_array() - .expect("source-local observations") - .iter() - .any(|observation| { - observation["classification"] == "lowConfidenceSymptom" - && observation["correlationEligible"] == false - && observation["evidence"] - .as_array() - .expect("observation evidence") - .iter() - .any(|reference| reference["artifactId"] == "mp-iis-control-current") - }), - "an mpcontrol source claiming MP production is a contract violation \ - and must surface as rejected evidence, not silent supplemental input" + 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" ); } From 751eac9209a1b15eb17a8bb63e8f12da6f444e92 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:11:57 -0400 Subject: [PATCH 240/422] test(sccm): probe two-subauthority SID free text The SID run check splits the whole string, so a trailing space or sentence-final period absorbs the last subauthority and leaves only one counted segment. Any s-1-A-B SID with A other than 5 escapes on the claim and next-artifact reason surfaces. Probes both free-text surfaces mid-sentence, sentence-final, parenthesized and comma-followed, and pins the designed acceptances plus the identifier surface as controls. Refs #326 --- ...sccm_client_management_fixture_contract.rs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index 690da3332..cb9e385ee 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -3504,3 +3504,120 @@ fn sid_authority_variant_mutations_fail_closed() { "bare s-1-5 identifier suffix must stay accepted" ); } + +#[test] +fn two_subauthority_sid_free_text_mutations_fail_closed() { + // Well-known SIDs that carry exactly two subauthorities outside the s-1-5 + // personal family: Nobody (s-1-0-0), Everyone (s-1-1-0), Local (s-1-2-0) + // and the integrity levels (s-1-16-4096, s-1-16-8192, s-1-16-12288). + const CLAIM_MUTATIONS: [(&str, &str); 5] = [ + ( + "mid-sentence Nobody SID", + "Observed records remain under s-1-0-0 markers.", + ), + ( + "sentence-final integrity SID", + "Observed records remain withheld at s-1-16-12288.", + ), + ( + "parenthesized Everyone SID", + "Observed records remain bounded (s-1-1-0) for this capture.", + ), + ( + "comma-followed Local SID", + "Observed records remain under s-1-2-0, and stay source local.", + ), + ( + "sentence-final Everyone SID", + "Observed records remain withheld at s-1-1-0.", + ), + ]; + const REASON_MUTATIONS: [(&str, &str); 5] = [ + ( + "mid-sentence Everyone SID", + "Collect the bounded continuation for s-1-1-0 records.", + ), + ( + "sentence-final Nobody SID", + "Collect the bounded continuation for s-1-0-0.", + ), + ( + "parenthesized integrity SID", + "Collect the bounded continuation (s-1-16-8192) for this group.", + ), + ( + "comma-followed integrity SID", + "Collect the bounded continuation for s-1-16-4096, then stop.", + ), + ( + "mid-sentence Local SID", + "Collect the bounded continuation for s-1-2-0 records.", + ), + ]; + + let mut accepted = Vec::new(); + + let (observed_root, observed_manifest, observed_expected) = + load_contract("software-center-observed"); + for (label, claim) in CLAIM_MUTATIONS { + let mut mutated = observed_expected.clone(); + mutated["sourceLocalObservations"][0]["claim"] = Value::String(claim.to_owned()); + if mutation_was_accepted( + "software-center-observed", + &observed_root, + &observed_manifest, + &mutated, + ) { + accepted.push(format!("{label} in a public observation claim")); + } + } + + let (deferred_root, deferred_manifest, deferred_expected) = + load_contract("notification-deferred"); + for (label, reason) in REASON_MUTATIONS { + let mut mutated = deferred_expected.clone(); + mutated["transactions"][0]["nextArtifact"]["reason"] = Value::String(reason.to_owned()); + if mutation_was_accepted( + "notification-deferred", + &deferred_root, + &deferred_manifest, + &mutated, + ) { + accepted.push(format!("{label} in a next-artifact request reason")); + } + } + + assert!( + accepted.is_empty(), + "two-subauthority SID free-text mutations were accepted: {accepted:?}" + ); + + // Designed acceptances that the token rule must preserve. + for accepted_text in [ + "Access remained denied for the s-1-5 authority marker.", + "Observed records remain under s-1-abc-123 markers.", + "Collect the bounded continuation for s-1-abc-123 records.", + ] { + assert!( + public_free_text_is_safe(accepted_text), + "{accepted_text:?} must stay accepted" + ); + } + + // The identifier surface already fails closed on these runs and must not move. + for identifier in [ + "software-center-observed-s-1-0-0", + "notification-deferred-s-1-16-12288", + ] { + assert!( + !public_identifier_is_safe(identifier), + "{identifier:?} must stay rejected as a public identifier" + ); + } + for identifier in ["software-center-observed-s-1-5", "s-1-abc-123"] { + assert!( + public_identifier_is_safe(identifier), + "{identifier:?} must stay accepted as a public identifier" + ); + } +} From 7fbab0cedfe129ac29d74f0b605bf94d8965ef0e Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:12:40 -0400 Subject: [PATCH 241/422] test(sccm): pin rotated capture source provenance Before this change the rotated smsts.lo_ artifact declared the active smsts.log path as its sanitized capture source, so the fixture recorded the active-log path as the physical source of the rotated file. Nothing failed, because the validator never bound sanitizedSourcePath to originalBasename or rotation kind, and its smstsLogPathEvidence equality actively forced capture provenance to equal the in-record observation that the specification keeps separate. Add two failing tests: one pins the rotated artifact's capture source to the smsts.lo_ path while keeping the in-record observation on smsts.log, the other mutates the basename in both directions and proves the validator accepts the mismatch. Refs #324 --- ...m_client_task_sequence_fixture_contract.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) 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 index b2a03f3b4..8f0b025b5 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -3084,6 +3084,78 @@ fn exact_key_admission_ignores_bracket_bounded_prefixes() { } } +#[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"; From b8da991a828f6f8197748d02fec51a5ee8169d4e Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:13:03 -0400 Subject: [PATCH 242/422] fix(sccm): screen SID runs per free-text token Hoist the edge-trimming tokenizer the network rule already used into a shared helper and run the SID check over those tokens, so a following space or sentence-final period no longer absorbs the last subauthority segment. The per-token predicate is unchanged, so bare s-1-5 and non-numeric runs such as s-1-abc-123 stay accepted, and the identifier surface is a single token by construction and keeps its exact behaviour. Refs #326 --- ...sccm_client_management_fixture_contract.rs | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs index cb9e385ee..9485670da 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_management_fixture_contract.rs @@ -352,6 +352,23 @@ fn source_version_matches_selected_profile(value: &str) -> bool { .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; @@ -396,17 +413,11 @@ fn public_free_text_is_safe(value: &str) -> bool { } let lower = value.to_ascii_lowercase(); - if lower.contains("s-1-5-") || contains_sid_shaped_run(&lower) { + if lower.contains("s-1-5-") || public_free_text_tokens(&lower).any(contains_sid_shaped_run) { return false; } - !value.split_ascii_whitespace().any(|token| { - let token = token.trim_matches(|character: char| { - matches!( - character, - '.' | ',' | ';' | ':' | '!' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '"' | '\'' - ) - }); + !public_free_text_tokens(value).any(|token| { let labels = token.split('.').collect::>(); if labels.iter().any(|label| label.is_empty()) { return false; From 7e6ed7b59c20dce451bade3c8f6b263eedb88095 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:13:58 -0400 Subject: [PATCH 243/422] fix(sccm): request the rejected record's owning group append_rejected_observations hardcoded MP_AUTH_GROUP in all three rejection branches, so any rejected policy-family record, including the fail-closed mpcontrol path, told the operator to collect MP_GetAuth.log instead of the artifact that actually holds the evidence. Partition the rejected references by the source group that owns them and emit one observation and finding per group, so each remediation hint requests its own family. Observation and finding ids are group-qualified to stay unique when a bundle rejects records from both groups. Deriving the phase from the owning group keeps it the inverse of group_for_phase, so an observation never cites a phase that maps back to a group it did not request; the same helper replaces the duplicated group-to-phase branches in the explicit-coverage path. Rejection identity, class, and reason move to an enum instead of matched string literals. Because a rejected policy record no longer claims the authentication group, an honest absent-auth coverage observation is no longer masked. Refs #328 --- .../sccm/server/windows/management_point.rs | 209 ++++++++++++------ 1 file changed, 139 insertions(+), 70 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs index 68857cbd1..b22bc08ac 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs @@ -1266,6 +1266,9 @@ fn append_rotation_fragment_observation( 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, @@ -1278,71 +1281,145 @@ fn append_rejected_observations( return; } - let unrelated = rejected.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 rotation = bundle .sources .iter() .any(|source| source.fragment_complete == Some(false)); - let (observation_id, finding_id, phase, classification, group, reason) = if unrelated { - ( - "observation:unrelated-client-like-key", - "finding:mp-unrelated-client-like-key", - SccmManagementPointPhase::ResolveLocationOrPolicy, - SccmManagementPointClassification::IncompatibleKey, - MP_AUTH_GROUP, - "Capture bounded MP_GetAuth.log evidence with the exact versioned request key.", - ) - } else if rotation { - ( - "observation:rotation-malformed", - "finding:mp-rotation-malformed", - SccmManagementPointPhase::ReceiveRequest, - SccmManagementPointClassification::LowConfidenceSymptom, - MP_AUTH_GROUP, - "Collect a supported-version MP_GetAuth.log record containing a complete exact request key.", - ) - } else { - ( - "observation:mp-malformed", - "finding:mp-malformed", - SccmManagementPointPhase::ReceiveRequest, - SccmManagementPointClassification::LowConfidenceSymptom, - MP_AUTH_GROUP, - "Collect bounded MP_GetAuth.log evidence under the validated extraction profile.", - ) - }; - let request = workflow_request_for_group(group, reason); - let evidence = merge_references(rejected.clone()); - observations.push(SccmManagementPointSourceLocalObservation { - observation_id: observation_id.to_owned(), - phase, - state: SccmManagementPointState::Observed, - classification, - confidence: SccmManagementPointConfidence::Low, - correlation_eligible: false, - evidence: evidence.clone(), - next_artifacts: vec![request.clone()], + 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 let Some(finding) = build_source_local_finding( - finding_id, - observation_id, - phase, - classification, - evidence, - None, - &[request], - ) { - findings.push(finding); + 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" } } @@ -1396,11 +1473,7 @@ fn append_unconsumed_explicit_coverage( let observation_id = format!("observation:coverage:{group}"); observations.push(SccmManagementPointSourceLocalObservation { observation_id: observation_id.clone(), - phase: if group == MP_AUTH_GROUP { - SccmManagementPointPhase::ReceiveRequest - } else { - SccmManagementPointPhase::ResolveLocationOrPolicy - }, + phase: entry_phase_for_group(group), state: SccmManagementPointState::Incomplete, classification: SccmManagementPointClassification::InsufficientEvidence, confidence: SccmManagementPointConfidence::Low, @@ -1416,11 +1489,7 @@ fn append_unconsumed_explicit_coverage( if let Some(finding) = build_source_local_finding( &format!("finding:mp-coverage:{group}"), &observation_id, - if group == MP_AUTH_GROUP { - SccmManagementPointPhase::ReceiveRequest - } else { - SccmManagementPointPhase::ResolveLocationOrPolicy - }, + entry_phase_for_group(group), SccmManagementPointClassification::InsufficientEvidence, Vec::new(), Some(&gap), From ccda4dfd796deac354d04351460cfa3305c37cf6 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:14:17 -0400 Subject: [PATCH 244/422] fix(sccm): name the rotated file in capture provenance Before this change the rotated smsts.lo_ artifact declared the active smsts.log path as its sanitized capture source, so the corpus recorded the active-log path as the physical source of the rotated file. The validator could not catch it: nothing bound the capture path basename to originalBasename, and the smstsLogPathEvidence check required the declared observation to equal the capture path, which forced the very conflation the specification forbids when it calls an observed _SMSTSLogPath the authoritative in-record observation and the capture path mere provenance. Point the rotated artifact at the smsts.lo_ path in both the manifest and the expected output while keeping its in-record observation on smsts.log, require every captured artifact's capture path to name its own originalBasename, and replace the observation equality with a directory binding so a declared observation must be physically present in these bytes and live beside the artifact it was captured from. Compare the rotated fragment against its reconstruction by directory, since only its basename may differ. Refs #324 --- .../rotation-boundary/expected.json | 2 +- .../rotation-boundary/manifest.json | 2 +- ...m_client_task_sequence_fixture_contract.rs | 27 ++++++++++++++++--- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/expected.json index 1ca03f63e..5447a42a6 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/expected.json @@ -9,7 +9,7 @@ "coverage": [{"logicalArtifactId":"client-task-sequence-smsts","state":"partial","pathClasses":["client"],"artifactIds":["task-sequence-rotation-boundary-current","task-sequence-rotation-boundary-lo"]}], "artifactProvenance": [ {"artifactId":"task-sequence-rotation-boundary-current","bytesCopied":141,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":null,"rotationKind":"current","fragmentComplete":false,"relocationOrdinal":0}, - {"artifactId":"task-sequence-rotation-boundary-lo","bytesCopied":259,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smsts.log","rotationKind":"lo","fragmentComplete":false,"relocationOrdinal":0} + {"artifactId":"task-sequence-rotation-boundary-lo","bytesCopied":259,"pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.lo_","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smsts.log","rotationKind":"lo","fragmentComplete":false,"relocationOrdinal":0} ], "logicalReconstructions": [ {"reconstructionId":"rotation-boundary-lo-current","logicalArtifactId":"client-task-sequence-smsts","orderedArtifactIds":["task-sequence-rotation-boundary-lo","task-sequence-rotation-boundary-current"],"pathFingerprint":"synthetic:rotation-boundary:client","pathClass":"client","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":{"artifactId":"task-sequence-rotation-boundary-lo","startLine":1,"endLine":1},"coverageState":"partial","confidence":"low","correlationEligible":false} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/manifest.json index 57f8102e7..8dc2dc6bc 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/task_sequence/rotation-boundary/manifest.json @@ -6,6 +6,6 @@ "bundle": {"role":"client","workflow":"taskSequence","siteCode":"LAB"}, "artifacts": [ {"artifactId":"task-sequence-rotation-boundary-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":null,"pathFingerprint":"synthetic:rotation-boundary:client","pathClass":"client","rotation":{"kind":"current","fragmentComplete":false},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:06Z","bytesCopied":141,"relativePath":"evidence/client-task-sequence-smsts/client/current/smsts.log"}, - {"artifactId":"task-sequence-rotation-boundary-lo","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.lo_","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.log","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smsts.log","pathFingerprint":"synthetic:rotation-boundary:client","pathClass":"client","rotation":{"kind":"lo","fragmentComplete":false},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:05Z","bytesCopied":259,"relativePath":"evidence/client-task-sequence-smsts/client/lo/smsts.lo_"} + {"artifactId":"task-sequence-rotation-boundary-lo","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.lo_","sanitizedSourcePath":"SYNTHETIC://client/CCM/Logs/smsts.lo_","smstsLogPathEvidence":"SYNTHETIC://client/CCM/Logs/smsts.log","pathFingerprint":"synthetic:rotation-boundary:client","pathClass":"client","rotation":{"kind":"lo","fragmentComplete":false},"relocationOrdinal":0,"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:05Z","bytesCopied":259,"relativePath":"evidence/client-task-sequence-smsts/client/lo/smsts.lo_"} ] } 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 index 8f0b025b5..8deeebb31 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -426,6 +426,14 @@ fn complete_field_tokens(record_text: &str) -> BTreeSet<&str> { body.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"), @@ -719,6 +727,11 @@ fn validate_manifest_and_storage( "{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); @@ -750,11 +763,16 @@ fn validate_manifest_and_storage( })?, ) }; + // 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 declared_path == sanitized_path - && observed_paths.len() == 1 - && observed_paths.contains(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" @@ -1086,7 +1104,8 @@ fn validate_contract( || current["rotation"]["fragmentComplete"] != false || lo["pathFingerprint"] != path_fingerprint || current["pathFingerprint"] != path_fingerprint - || lo["sanitizedSourcePath"] != sanitized_path + || lo["sanitizedSourcePath"].as_str().map(sanitized_parent) + != Some(sanitized_parent(sanitized_path)) || current["sanitizedSourcePath"] != sanitized_path || lo["pathClass"] != path_class || current["pathClass"] != path_class From 531e8e85d6684b34f4907431aa22e8a856e0dc01 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:28:37 -0400 Subject: [PATCH 245/422] test(sccm): pin evidence identity and body framing Before this change evidence references were compared as whole JSON values, but a reference is an open object and only artifactId, startLine, and endLine are read when resolving it. Adding one ignored key made two references unequal while both still resolved to the same physical record, so a padded observation could alias keyed evidence and a single finding could pool both unrelated exact runs while appearing source-local. Separately complete_field_tokens fell back to the raw line whenever the cited text did not start exactly with the CCM opener, exposing the time and context trailer to key admission. Add three failing tests: a control pair proving an unpadded alias is rejected while a padded alias is accepted, the cross-run laundering shape through two padded observations, and an indented record whose relocated key fields are admitted from its trailer. Each must fail closed. Refs #324 --- ...m_client_task_sequence_fixture_contract.rs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) 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 index 8deeebb31..c2139ecbf 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -3103,6 +3103,123 @@ fn exact_key_admission_ignores_bracket_bounded_prefixes() { } } +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"); From d1d163b3069b5c00c8d9b17663ecac884fd8efbe Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:30:33 -0400 Subject: [PATCH 246/422] fix(sccm): compare evidence by identity, close bodies Before this change evidence references were compared as whole JSON values while only artifactId, startLine, and endLine are read to resolve one. One ignored key therefore made two references to the same physical record compare as different citations, so a padded observation aliased keyed evidence and a finding pooled both unrelated exact runs while matching only the laundering observations. complete_field_tokens also fell back to the raw cited line when the CCM opener or terminator was missing, exposing the time and context trailer to key admission. Reject any evidence reference carrying keys outside the identity triple, and compare every citation on that triple in the ordering, terminal, transaction-membership, observation disjointness, and source-local paths. Return no token set when a record body cannot be delimited and fail the citation closed, since the framing check already proves a cited range is one complete CCM record. Note why the ordering and terminal disjuncts are redundant today. Refs #324 --- ...m_client_task_sequence_fixture_contract.rs | 118 +++++++++++++----- 1 file changed, 87 insertions(+), 31 deletions(-) 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 index c2139ecbf..6bda9bf81 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -327,6 +327,55 @@ fn corpus_inventory() -> CorpusInventory { } } +/// 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) => { @@ -411,19 +460,15 @@ fn smsts_log_paths(contents: &str) -> BTreeSet { .collect() } -fn complete_field_tokens(record_text: &str) -> BTreeSet<&str> { - // 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. - let body = record_text - .strip_prefix("") - .map(|terminator| &after_prefix[..terminator]) - }) - .unwrap_or(record_text); - body.split_whitespace().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 { @@ -834,6 +879,7 @@ fn validate_contract( 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 @@ -1327,7 +1373,9 @@ fn validate_contract( } let record_text = evidence_text(scenario_root, &artifacts_by_id, evidence_ref)?; - let record_tokens = complete_field_tokens(&record_text); + 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())) @@ -1503,7 +1551,7 @@ fn validate_contract( let ordering_ref = &transaction["orderingEvidence"]; if !evidence_refs .iter() - .any(|evidence_ref| evidence_ref == ordering_ref) + .any(|evidence_ref| same_evidence_reference(evidence_ref, ordering_ref)) { return Err(format!( "{transaction_id}: ordering evidence is not key-bound transaction evidence" @@ -1636,10 +1684,9 @@ fn validate_contract( "{transaction_id}: terminal outcome lacks terminal evidence" )); } - if !evidence_refs - .iter() - .any(|evidence_ref| evidence_ref == &transaction["terminalEvidence"]) - { + 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" )); @@ -1694,16 +1741,25 @@ fn validate_contract( 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| transaction_ref == &observation["evidence"]) + transaction_evidence.iter().any(|transaction_ref| { + same_evidence_reference(transaction_ref, &observation["evidence"]) + }) }) - || transaction["orderingEvidence"] == observation["evidence"] - || transaction["terminalEvidence"] == observation["evidence"] + || same_evidence_reference( + &transaction["orderingEvidence"], + &observation["evidence"], + ) + || same_evidence_reference( + &transaction["terminalEvidence"], + &observation["evidence"], + ) }); if cites_keyed_transaction_evidence { return Err(format!( @@ -1790,18 +1846,18 @@ fn validate_contract( .as_array() .is_some_and(|transaction_evidence| { evidence.iter().all(|evidence_ref| { - transaction_evidence - .iter() - .any(|transaction_ref| transaction_ref == 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| &observation["evidence"] == evidence_ref) + observations.iter().any(|observation| { + same_evidence_reference(&observation["evidence"], evidence_ref) + }) }); let outcome_is_transaction_bound = match classification { "success" | "confirmedFailure" => { @@ -1809,7 +1865,7 @@ fn validate_contract( !transaction["terminalEvidence"].is_null() && evidence .iter() - .any(|evidence_ref| evidence_ref == &transaction["terminalEvidence"]) + .any(|evidence_ref| same_evidence_reference(evidence_ref, &transaction["terminalEvidence"])) }) } "blockedOrDeferred" => binding_transactions.len() == 1, From 0006afa265074556ded623ef06f41fd9baaa73ac Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:52:00 -0400 Subject: [PATCH 247/422] test(ccm): freeze the ambiguous time-tail table Pin the fractional text, milliseconds, source offset and ordering state for unsigned CCM time tails of one to eight digits and their signed counterparts, plus explicit negative cases for both historical offset bugs. A six-digit microsecond tail such as .123456 currently yields a fabricated +456 minute offset at NormalizedUtc confidence. --- crates/cmtraceopen-parser/src/parser/ccm.rs | 146 ++++++++++++++++++ .../tests/sccm_spine_contract.rs | 33 ++++ 2 files changed, 179 insertions(+) diff --git a/crates/cmtraceopen-parser/src/parser/ccm.rs b/crates/cmtraceopen-parser/src/parser/ccm.rs index 9c4e301d8..0073ae4e9 100644 --- a/crates/cmtraceopen-parser/src/parser/ccm.rs +++ b/crates/cmtraceopen-parser/src/parser/ccm.rs @@ -970,4 +970,150 @@ mod tests { ); assert!(records[0].timestamp.utc_millis.is_some()); } + + fn logical_record_for_time_tail(tail: &str) -> 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/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 0e5428f44..d8b6f7cef 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -4477,6 +4477,39 @@ fn signless_ccm_offset_is_enriched_only_in_sccm_provenance() { 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"); From c0486b3fbe4babb2a89f63bec48323e51f311115 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:52:28 -0400 Subject: [PATCH 248/422] test(sccm): pin outcome claims to record body tokens Before this change key admission read only the delimited record body, but the phase, state, and terminal bindings still ran raw substring searches over the whole cited physical line. The time and context trailer that key admission excludes was therefore still admitted for the outcome claims, and a substring search has no token boundary, so a longer value satisfied a shorter declared one. Add a failing table covering six shapes against the strongest claim in the corpus: phase and state relocated into the trailer, terminal relocated into the trailer, and suffixed or extended phase, state, and terminal tokens left in the body while the contract keeps declaring success at high confidence. All six must fail closed. Refs #324 --- ...m_client_task_sequence_fixture_contract.rs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) 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 index 6bda9bf81..1bb663f5e 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -3387,3 +3387,90 @@ fn keyed_evidence_cannot_be_laundered_through_source_local_observations() { .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(", ") + ); +} From eab83fac7ad8af7d3c0b47a7bcdbd8152d5a138f Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:53:22 -0400 Subject: [PATCH 249/422] fix(ccm): validate signless time-tail offsets A six-digit unsigned fractional tail is ambiguous: the legacy %03u%d grammar writes three millisecond digits plus an unsigned positive offset, while .NET writers emit six microsecond digits. Splitting on digit width alone turned .123456 into 123 ms at UTC+456 minutes, a silent 7h36m shift reported as NormalizedUtc. Split the tail only when the trailing run is a real timezone offset: no zero padding (%d never pads), within UTC-14..UTC+14, and a whole quarter hour. Tails that fail the check keep every digit as fractional seconds and are reported with no source offset, so ordering downgrades to OffsetMissing instead of guessing. Signed offsets are untouched. --- crates/cmtraceopen-parser/src/parser/ccm.rs | 50 ++++++++++++++++++--- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/crates/cmtraceopen-parser/src/parser/ccm.rs b/crates/cmtraceopen-parser/src/parser/ccm.rs index 0073ae4e9..6e6238c2d 100644 --- a/crates/cmtraceopen-parser/src/parser/ccm.rs +++ b/crates/cmtraceopen-parser/src/parser/ccm.rs @@ -170,12 +170,50 @@ 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. The documented legacy `%03u%d` -/// grammar also permits a signless three-digit offset after exactly three -/// millisecond digits. Other digit-only tails are fractional seconds with no -/// source offset, including the seven-digit precision emitted by IME logs. +/// 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() @@ -185,7 +223,9 @@ fn split_ccm_time_tail(value: &str) -> (&str, Option<&str>) { return (&value[..index], Some(&value[index..])); } - if value.len() == 6 { + // `%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..])); } From 63e4ad41551f3e5b344fe0556cd722f621ca0a27 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:54:22 -0400 Subject: [PATCH 250/422] fix(sccm): bind outcome claims to delimited body tokens Before this change the phase, state, and terminal bindings ran raw substring searches over the whole cited physical line, so the time and context trailer that key admission already excludes was still admitted for the outcome claims, and a substring match let a longer recorded value satisfy a shorter declared one. The strongest claim in the corpus survived moving phase, state, or terminal out of the record body into the trailer. Carry the delimited body token set forward from key admission instead of the raw line, and take the terminal record through the same helper, so all four bindings share one body edge and one complete-token rule. The framing check already proves a cited range is one complete CCM record, so an undelimitable terminal body fails closed. No fixture changed: every shipped record already carries these fields inside its body as complete tokens. Refs #324 --- ...m_client_task_sequence_fixture_contract.rs | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) 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 index 1bb663f5e..ae22c3e55 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence_fixture_contract.rs @@ -1351,7 +1351,7 @@ fn validate_contract( .ok_or_else(|| format!("{transaction_id}: key {field} is not a string")) }) .collect::, _>>()?; - let mut cited_record_texts = Vec::new(); + 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"] @@ -1384,7 +1384,12 @@ fn validate_contract( "{transaction_id}: declared key fields do not co-occur as complete tokens in cited complete CCM record ({missing_needle})" )); } - cited_record_texts.push(record_text); + cited_record_token_sets.push( + record_tokens + .into_iter() + .map(str::to_owned) + .collect::>(), + ); } let phase = transaction["phase"] @@ -1399,9 +1404,9 @@ fn validate_contract( if !STATE_CHAIN.contains(&phase) || !STATE_CHAIN.contains(&last_successful_phase) || !["inProgress", "blockedOrDeferred", "failed", "succeeded"].contains(&state) - || !cited_record_texts.iter().any(|record_text| { - record_text.contains(&format!("phase={phase}")) - && record_text.contains(&format!("state={state}")) + || !cited_record_token_sets.iter().any(|record_tokens| { + record_tokens.contains(&format!("phase={phase}")) + && record_tokens.contains(&format!("state={state}")) }) { return Err(format!( @@ -1696,9 +1701,14 @@ fn validate_contract( &artifacts_by_id, &transaction["terminalEvidence"], )?; - if !terminal_text.contains("terminal=true") - || !terminal_text.contains(&format!("state={terminal_state}")) - || !terminal_text.contains(&format!("phase={phase}")) + 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" From cc6bf796c524e8794cbe7562f57fcff395cb5de6 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 1 Aug 2026 18:04:41 -0400 Subject: [PATCH 251/422] ci: gate SCCM integration branch Port the trigger-only change from #421 onto the SCCM integration branch.\n\nRefs #317. --- .github/workflows/cmtrace-ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cmtrace-ci.yml b/.github/workflows/cmtrace-ci.yml index 208e04bbc..653d464d6 100644 --- a/.github/workflows/cmtrace-ci.yml +++ b/.github/workflows/cmtrace-ci.yml @@ -2,9 +2,11 @@ name: "CMTrace Open: CI" on: push: - branches: [main] + branches: [main, codex/parser-family-skeleton] pull_request: - branches: [main] + # Long-lived integration branches must be listed explicitly: a PR whose base + # is absent here runs no jobs at all, silently, and still reports mergeable. + branches: [main, codex/parser-family-skeleton] permissions: contents: read From 56818acd24357c579a7f69d66550836275f98940 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 1 Aug 2026 18:11:09 -0400 Subject: [PATCH 252/422] test(sccm): pin server identity provenance --- .../tests/sccm_server_intake.rs | 104 +++++++++++++++++- 1 file changed, 100 insertions(+), 4 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index c3e319c4c..bce12e84b 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -1,4 +1,6 @@ -use cmtraceopen_parser::sccm::server::windows::{assess_server_intake, SccmServerArtifactPayload}; +use cmtraceopen_parser::sccm::server::windows::{ + assess_server_intake, SccmServerArtifactPayload, SccmServerIntakeError, +}; use cmtraceopen_parser::sccm::{SccmCoverageState, SccmRole, SccmRotation}; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; @@ -306,9 +308,103 @@ fn server_intake_rejects_relabelled_duplicate_canonical_artifact_identity() { manifest["artifacts"][1]["configuredPathProvenance"]["pathFingerprint"] = fingerprint; manifest["artifacts"][1]["rotation"]["lineageId"] = lineage; - assert!( - assess_server_intake(&serialize_manifest(&manifest), &payloads).is_err(), - "caller-chosen artifact and root labels must not duplicate one canonical identity" + 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"][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(&serialize_manifest(&manifest), &payloads) + .expect("the same artifact identity on a distinct producer host is independent"); + assert_eq!(assessment.artifacts.len(), 2); +} + +#[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); + configure_second_artifact_as_dp_identity(&mut manifest, "synthetic:subject:sup-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); +} + +#[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:sup-01", 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", ); } From 2e0bd691f6a1a9e7de343fbb6207e96b8aedb78b Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 1 Aug 2026 18:15:00 -0400 Subject: [PATCH 253/422] fix(sccm): scope server identities by provenance --- .../src/sccm/server/windows/intake.rs | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index f5e4abdd6..088525b07 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -12,8 +12,25 @@ use crate::sccm::{ use super::catalog::{classify_declared_server_source, expected_family, SccmServerSourceKind}; -type PathFingerprintKey = (String, String, String, String); -type CanonicalArtifactIdentity = (String, String, String, String, String, String, String); +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 { @@ -463,12 +480,17 @@ fn normalize_artifact( 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 @@ -487,12 +509,17 @@ fn normalize_artifact( 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 From b3ebb5b1414a18eb180c654ae34479f3b6a51132 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 1 Aug 2026 18:28:47 -0400 Subject: [PATCH 254/422] fix(sccm): order server artifacts by provenance --- .../src/sccm/server/windows/intake.rs | 19 +++++- .../tests/sccm_server_intake.rs | 58 ++++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 088525b07..61a654449 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -310,10 +310,23 @@ struct PreparedArtifact { } impl PreparedArtifact { - fn sort_key(&self) -> (&str, &str, &str, String, &str, &str) { + 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 @@ -1055,7 +1068,9 @@ fn safe_optional_handle(value: Option<&str>, synthetic_fixture: bool, domain: &s "subject" => { matches!( value, - "synthetic:subject:dp-01" | "synthetic:subject:sup-01" + "synthetic:subject:dp-01" + | "synthetic:subject:dp-02" + | "synthetic:subject:sup-01" ) } _ => false, diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index bce12e84b..da70b78e4 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -322,14 +322,38 @@ fn server_intake_scopes_canonical_identity_to_producer_host() { let fingerprint = manifest["artifacts"][0]["configuredPathProvenance"]["pathFingerprint"].clone(); let lineage = manifest["artifacts"][0]["rotation"]["lineageId"].clone(); - manifest["artifacts"][1]["producerHostHandle"] = + 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] @@ -377,18 +401,46 @@ fn configure_second_artifact_as_dp_identity( 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); - configure_second_artifact_as_dp_identity(&mut manifest, "synthetic:subject:sup-01", true); + 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_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:sup-01", false); + 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"); From 681a0706cdea312760be458f394c351a68d35159 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:49:24 -0400 Subject: [PATCH 255/422] fix(sccm): map server intake artifact requests (#335) Map server intake gaps to validated shared-catalog artifact requests and scope default-candidate suppression to exact producer-host and workflow-subject provenance. Includes focused adversarial coverage for cross-host and cross-subject isolation. --- .../src/sccm/server/windows/intake.rs | 57 ++++--- .../tests/sccm_server_intake.rs | 150 +++++++++++++++++- 2 files changed, 184 insertions(+), 23 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 61a654449..f69ad168c 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -6,8 +6,8 @@ use serde_json::Value; use thiserror::Error; use crate::sccm::{ - normalize_ccm_artifact, SccmArtifact, SccmArtifactFamily, SccmArtifactRequest, - SccmCoverageState, SccmEvidence, SccmFinding, SccmRole, SccmRotation, + classify_artifact_name, normalize_ccm_artifact, SccmArtifact, SccmArtifactFamily, + SccmArtifactRequest, SccmCoverageState, SccmEvidence, SccmFinding, SccmRole, SccmRotation, }; use super::catalog::{classify_declared_server_source, expected_family, SccmServerSourceKind}; @@ -873,9 +873,16 @@ fn normalize_collected_utc(value: &str) -> Result .to_rfc3339_opts(SecondsFormat::Secs, true)) } -fn logical_source_key(artifact: &SccmServerArtifactAssessment) -> (String, String, String) { +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 @@ -883,6 +890,11 @@ fn logical_source_key(artifact: &SccmServerArtifactAssessment) -> (String, Strin .map(role_sort_key) .unwrap_or_default() .to_owned(), + artifact + .workflow_subject_handle + .as_deref() + .unwrap_or_default() + .to_owned(), ) } @@ -896,22 +908,31 @@ fn request_for_gap( { return None; } - let reason = match artifact.state { - SccmCoverageState::Absent => "source was absent; role outcome remains unknown", - SccmCoverageState::AccessDenied => "source access was denied; role outcome remains unknown", - SccmCoverageState::Capped => "source was capped; terminal role evidence is incomplete", - SccmCoverageState::ParseFailed => { - "source parsing failed; terminal role evidence is unavailable" - } - _ => return None, - }; + if !matches!( + artifact.state, + SccmCoverageState::Absent + | SccmCoverageState::AccessDenied + | SccmCoverageState::Capped + | 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: artifact.source_id.clone(), - role: artifact - .workflow_subject_role - .clone() - .unwrap_or_else(|| artifact.producer_role.clone()), - reason: reason.to_owned(), + logical_id: classified.logical_name, + role: classified.role, + reason: format!("Collect the complete {} file.", classified.basename), }) } diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index da70b78e4..bf65aaeff 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -1,7 +1,11 @@ +use cmtraceopen_parser::models::log_entry::Severity; use cmtraceopen_parser::sccm::server::windows::{ assess_server_intake, SccmServerArtifactPayload, SccmServerIntakeError, }; -use cmtraceopen_parser::sccm::{SccmCoverageState, SccmRole, SccmRotation}; +use cmtraceopen_parser::sccm::{ + SccmConfidence, SccmCoverageState, SccmFinding, SccmFindingBuilder, SccmFindingClass, + SccmFindingCoverageGap, SccmPhase, SccmRole, SccmRotation, +}; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; @@ -74,6 +78,43 @@ fn artifact_json<'a>(assessment: &'a Value, artifact_id: &str) -> &'a Value { .expect("artifact is present") } +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"); @@ -137,10 +178,7 @@ fn server_intake_normalizes_role_coverage_and_logical_records() { 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, - "server-dp-distribution" - ); + assert_eq!(absent.next_artifact_requests[0].logical_id, "distmgr"); let (unsorted_manifest, unsorted_payloads) = load_bundle("unsorted-manifest"); let unsorted = @@ -163,6 +201,64 @@ fn server_intake_normalizes_role_coverage_and_logical_records() { ); } +#[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()); + 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| { @@ -569,6 +665,50 @@ fn server_intake_suppresses_absent_default_request_when_configured_source_is_usa ); } +#[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["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 = [ From d5beb5bbb1da9c8e6b8fab22af14dee792da74c1 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:23:59 -0400 Subject: [PATCH 256/422] fix(sccm): reject overlapping evidence ranges in the spine (#418) Reject overlapping bounded evidence ranges across all SCCM finding citation surfaces, preserve validation precedence, and reuse the shared predicate in Management Point analysis. Verified by focused spine and Management Point contracts, full parser tests, wasm32, strict Clippy, formatting/diff checks, CodeRabbit, Copilot, and hosted Linux/macOS/Windows CI/package builds. --- .../cmtraceopen-parser/src/sccm/findings.rs | 107 +++++++- .../sccm/server/windows/management_point.rs | 17 +- .../tests/sccm_spine_contract.rs | 228 +++++++++++++++++- 3 files changed, 324 insertions(+), 28 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 3e4fb3284..d14a2afda 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -457,6 +457,7 @@ pub enum SccmFindingValidationError { InvalidRole, InvalidEvidenceReference, ConflictingEvidenceReference, + OverlappingEvidenceReference, MissingEvidenceOrCoverageGap, MissingTerminalEvidence, InvalidTerminalEvidence, @@ -644,11 +645,9 @@ fn validate_roles(finding: &SccmFinding) -> Result<(), SccmFindingValidationErro Ok(()) } -fn validate_all_evidence_references( - finding: &SccmFinding, -) -> Result<(), SccmFindingValidationError> { - let mut ranges_by_identity = BTreeMap::new(); - for reference in finding +/// Every reference a finding cites, across all three citation surfaces. +fn cited_evidence_references(finding: &SccmFinding) -> impl Iterator { + finding .evidence .iter() .chain( @@ -663,17 +662,103 @@ fn validate_all_evidence_references( .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)?; - let identity = (reference.artifact_id.as_str(), reference.entry_id.as_str()); - let range = (reference.line_start, reference.line_end); - if ranges_by_identity - .insert(identity, range) - .is_some_and(|existing| existing != range) + 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(()) } diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs index b22bc08ac..2eb312dc7 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs @@ -15,6 +15,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::Serialize; 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, @@ -1127,22 +1128,6 @@ fn evidence_identity_is_unique( == 1 } -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 - ) -} - fn safe_evidence_reference(reference: &SccmEvidenceRef) -> bool { safe_opaque_id(&reference.artifact_id) && safe_opaque_id(&reference.entry_id) diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index d8b6f7cef..5320aa881 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -908,7 +908,16 @@ fn finding_unregistered_strong_or_exact_key_profiles_are_rejected() { #[test] fn finding_rejects_key_or_terminal_refs_that_are_not_cited() { let cited = finding_evidence_ref("client-policy-agent", "policy:1-1"); - let missing = finding_evidence_ref("client-policy-agent", "policy:2-2"); + // `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) @@ -1250,6 +1259,223 @@ fn finding_serialization_prioritizes_conflicting_evidence_identity_ranges() { 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); From 34c3abaa17baeb5c1f61e0254213bd017fdd7038 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:11:23 -0400 Subject: [PATCH 257/422] fix(sccm): bound opaque ids and harden finding deserialization (#404) * test(sccm): prove finding text and id fields are unbounded RED: deserialized findings currently accept arbitrarily long finding ids, evidence artifact and entry ids, titles, summaries, correlation-key raw and normalized values, and extraction profile ids. Only the coverage-gap artifact id is bounded today. Refs #317 * fix(sccm): bound every finding id and display string is_canonical_opaque_id checked only non-empty and trimmed, so finding ids, evidence artifact and entry ids, artifact request logical ids, and extraction profile ids were unbounded on the wire. Only the coverage-gap artifact id carried a length bound. Rename the constant to MAX_SCCM_OPAQUE_ID_CHARS and enforce it inside is_canonical_opaque_id so no identifier path can skip it, then drop the now-redundant explicit check in validate_coverage_gaps. Bound title, summary, and correlation-key raw and normalized values, following the existing request-reason precedent of trimming first and counting chars, not bytes. Fail-closed tightening: previously accepted values stay accepted. Refs #317 * test(sccm): prove gap and request deser skip the wire contract RED: SccmFindingCoverageGap and SccmArtifactRequest are public and derive Deserialize directly, so they accept unknown fields and skip every validation SccmFinding applies to the same payloads. Captured coverage, empty, untrimmed, overlong, and undeclared ids, and unbounded reasons all deserialize today. Refs #317 * fix(sccm): route gap and request deser through the wire contract SccmFindingCoverageGap and SccmArtifactRequest derived Deserialize directly, so standalone payloads bypassed deny_unknown_fields and every check SccmFinding applies to the same values. Replace the derive with manual impls over the existing wire structs and validators, matching how SccmFinding::deserialize already works. Both types still implement Deserialize, so this is source compatible for consumers; only payloads the spine already considered invalid stop deserializing. Nothing outside the finding wire path deserializes either type: SccmServerIntakeAssessment is Serialize only and no caller names them in a Deserialize position. Refs #317 * test(sccm): prove three more public types skip the wire contract RED: SccmEvidenceRef, SccmTerminalEvidence, and SccmCorrelationKey are public, re-exported SccmFinding members that still derive Deserialize directly, so standalone payloads bypass deny_unknown_fields and every check the finding applies. Proven accepted today: 5000-char artifact ids, untrimmed and empty entry ids, incoherent line ranges, unknown fields at both the outer and nested level, non-failure terminal kinds, 5000-char key values, an incoherent 99999..1 span, and overlong profile ids. The serious case is confidence forging. Because REGISTERED_STABLE_CORRELATION_PROFILE_IDS is deliberately empty, validate_correlation_key_evidence holds every key at Low, yet a standalone payload deserializes confidence exact or strong and forges a trust signal no registered profile can authorize. Refs #317 * fix(sccm): close the last three unvalidated deser doors SccmEvidenceRef, SccmTerminalEvidence, and SccmCorrelationKey were the remaining public SccmFinding members whose derived Deserialize skipped deny_unknown_fields and every finding-level check. Route all three through their existing wire structs and validators, matching the coverage-gap and artifact-request pattern. Standalone payloads have no surrounding finding, so a terminal evidence and a correlation key stand as their own citation set; that satisfies the citation rule while leaving every other gate in force. The gate that matters is confidence. While REGISTERED_STABLE_CORRELATION_PROFILE_IDS stays empty, no payload can now deserialize a Strong or Exact key and forge corroboration strength that no registered profile authorizes. SccmEvidenceRef and SccmCorrelationKey are declared in models.rs, so their impls live in findings.rs beside the validators they must satisfy. This also tightens SccmEvidence, SccmExtractionGap, and SccmKeyExtractionResult, which embed them; extraction output still round trips because extract_keys already downgrades every emitted key to Low. Also record that MAX_SCCM_CORRELATION_KEY_VALUE_CHARS must not be lowered: a worst-case ServerHost key sits at about 99% of it. Refs #317 * test(sccm): prove nested key evidence skips the contract SccmCorrelationKeyWire carries evidence as Option, so a nested reference never reaches the SccmEvidenceRef deserializer. validate_correlation_key_evidence only checks that the citation set contains the reference, and the key's own reference is that set, so the check is self-satisfying and the reference itself is never validated. Share one noncanonical reference payload list across the standalone evidence-ref door, the terminal-evidence door, and the correlation-key door so no door can be tested against a weaker list than its siblings, and cover the SccmKeyExtractionResult path that nests keys. Also cover the nested role each of the coverage-gap and artifact-request wire doors carries. Refs #317 * fix(sccm): validate nested correlation key evidence validate_correlation_key_evidence now validates the reference itself before the containment check, so every door reaches the same bar: the standalone key deserializer, the SccmKeyExtractionResult path that nests keys, SccmFinding::validate, the builder, and the serializer. Placed inside the validator rather than at the deserializer call site because the defect was a call site that omitted the check. A caller cannot omit a check the validator performs. Refs #317 * test(sccm): prove extract_keys emits rejected keys MAX_SCCM_CORRELATION_KEY_VALUE_CHARS is enforced at the validator but not at the producer, so extract_keys can emit a key that fails the crate's own contract and no longer round trips. Covers the raw value crossing the bound, and a KB id whose raw stays inside the bound and only crosses it once normalization prepends "KB", so the producer has to weigh the normalized value too. Also pins the out-of-bound candidate as a recorded gap rather than a silent drop. Refs #317 * fix(sccm): bound correlation key values at the producer extract_keys now weighs an out-of-bound value the same way it weighs a value that fails to normalize: the candidate becomes a MalformedCandidate gap instead of a key, so it stays visible and the producer stops emitting keys the crate's own validator rejects. Weighs the normalized value as well as the raw one, since normalize_kb_id prepends "KB" and can push a bounded raw past the bound. MAX_SCCM_CORRELATION_KEY_VALUE_CHARS becomes crate-visible so the producer and the validator share one number rather than two that can drift. It stays out of the public surface: it is an internal agreement, not wire surface. Refs #317 * fix(sccm): bound stored finding request text * fix(sccm): validate standalone artifact requests * fix(sccm): validate standalone citation serialization * perf(sccm): fail fast on oversized extracted keys --- .../cmtraceopen-parser/src/sccm/findings.rs | 325 ++++- crates/cmtraceopen-parser/src/sccm/keys.rs | 16 +- crates/cmtraceopen-parser/src/sccm/models.rs | 14 +- .../tests/sccm_spine_contract.rs | 1119 ++++++++++++++++- 4 files changed, 1451 insertions(+), 23 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index d14a2afda..c38d16497 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -1,5 +1,6 @@ use std::cmp::Ordering; use std::collections::BTreeMap; +use std::slice; use serde::de::Error as _; use serde::ser::Error as _; @@ -16,7 +17,27 @@ use super::models::{ pub const MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS: usize = 240; pub const MAX_SCCM_NEXT_ARTIFACT_REQUESTS: usize = 16; -const MAX_SCCM_COVERAGE_GAP_ARTIFACT_ID_CHARS: usize = 256; +// 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; // Intentionally empty: no extraction profile is verified as stable enough to // authorize key-only High confidence. Adding one requires contract review. const REGISTERED_STABLE_CORRELATION_PROFILE_IDS: &[&str] = &[]; @@ -152,8 +173,7 @@ impl<'de> Deserialize<'de> for SccmTerminalEvidenceKind { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SccmTerminalEvidence { pub reference: SccmEvidenceRef, pub kind: SccmTerminalEvidenceKind, @@ -168,22 +188,46 @@ impl SccmTerminalEvidence { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, PartialEq)] pub struct SccmFindingCoverageGap { pub artifact_id: String, pub role: SccmRole, pub coverage: SccmCoverageState, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +#[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, @@ -269,6 +313,35 @@ struct SccmFindingWire { 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 { @@ -289,6 +362,54 @@ impl From for SccmEvidenceRef { } } +// 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 { @@ -305,6 +426,56 @@ impl From for SccmTerminalEvidence { } } +// 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 { @@ -323,6 +494,58 @@ impl From for SccmFindingCoverageGap { } } +// 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 { @@ -351,6 +574,28 @@ impl From for SccmCorrelationKey { } } +// 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 { @@ -369,6 +614,23 @@ impl From for SccmArtifactRequest { } } +// 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 @@ -393,6 +655,9 @@ impl<'de> Deserialize<'de> for SccmFinding { 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:?}")) @@ -403,6 +668,7 @@ impl<'de> Deserialize<'de> for SccmFinding { 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)?; @@ -612,16 +878,35 @@ impl SccmFindingBuilder { 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) - || finding.title.trim().is_empty() - || finding.summary.trim().is_empty() + || title.is_empty() + || summary.is_empty() || !finding.phase.has_canonical_serialized_form() { return Err(SccmFindingValidationError::MissingRequiredField); @@ -784,9 +1069,7 @@ fn validate_coverage_gaps( ) -> 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.artifact_id.chars().count() > MAX_SCCM_COVERAGE_GAP_ARTIFACT_ID_CHARS - || gap.coverage == SccmCoverageState::Captured + if !is_canonical_opaque_id(&gap.artifact_id) || gap.coverage == SccmCoverageState::Captured { return Err(SccmFindingValidationError::InvalidCoverageGap); } @@ -845,11 +1128,11 @@ fn is_bounded_request_reason( requested_logical_id: &str, ) -> bool { let trimmed = reason.trim(); - if trimmed.is_empty() + if !has_at_most_chars(reason, MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS) + || trimmed.is_empty() || !trimmed .chars() .any(|character| character.is_ascii_alphanumeric()) - || trimmed.chars().count() > MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS || contains_rooted_path(trimmed) || trimmed.contains(['*', '?', '[', ']']) { @@ -2130,8 +2413,10 @@ fn validate_correlation_key_evidence( 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) { @@ -2161,6 +2446,12 @@ fn validate_correlation_key_evidence( 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); } @@ -2227,7 +2518,11 @@ fn evidence_identity(reference: &SccmEvidenceRef) -> (&str, &str) { } fn is_canonical_opaque_id(value: &str) -> bool { - !value.is_empty() && value.trim() == value + !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) { diff --git a/crates/cmtraceopen-parser/src/sccm/keys.rs b/crates/cmtraceopen-parser/src/sccm/keys.rs index a019684a0..c1821d450 100644 --- a/crates/cmtraceopen-parser/src/sccm/keys.rs +++ b/crates/cmtraceopen-parser/src/sccm/keys.rs @@ -2,6 +2,7 @@ use std::sync::OnceLock; use regex::Regex; +use super::findings::{has_at_most_chars, MAX_SCCM_CORRELATION_KEY_VALUE_CHARS}; use super::models::{ SccmCorrelationKey, SccmCorrelationKeyKind, SccmEvidence, SccmExtractionGap, SccmExtractionGapKind, SccmExtractionProfile, SccmExtractionProfileMaturity, SccmKeyConfidence, @@ -179,7 +180,12 @@ pub fn extract_keys( for candidate in candidates { let mut key = normalize_key(candidate.kind.clone(), candidate.raw); - if key.confidence != SccmKeyConfidence::Exact { + // 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, @@ -200,6 +206,14 @@ pub fn extract_keys( 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) -> Option { if profile.selected_configmgr_version.is_none() { return Some(SccmExtractionGapKind::MissingVersion); diff --git a/crates/cmtraceopen-parser/src/sccm/models.rs b/crates/cmtraceopen-parser/src/sccm/models.rs index a87ba7802..7772e0403 100644 --- a/crates/cmtraceopen-parser/src/sccm/models.rs +++ b/crates/cmtraceopen-parser/src/sccm/models.rs @@ -147,8 +147,11 @@ pub struct SccmTimestamp { pub ordering_state: SccmTimeOrderingState, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +// 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, @@ -203,8 +206,11 @@ pub enum SccmKeyConfidence { Exact, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] +// 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, diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 5320aa881..933c47126 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -80,6 +80,52 @@ fn finding_key( } } +/// 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(), @@ -781,8 +827,18 @@ fn finding_review_invalid_profiled_keys_fail_at_every_public_boundary() { 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::to_value([key]).unwrap(); + json["correlationKeys"] = serde_json::json!([key_json]); if serde_json::from_value::(json).is_ok() { accepted.push(format!("deserializer: {label}")); } @@ -1583,6 +1639,997 @@ fn finding_rejects_noncanonical_opaque_ids_across_public_boundaries() { 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") @@ -2628,8 +3675,11 @@ fn finding_review_passive_unbounded_confirmation_requests_fail_at_every_public_b } let mut json = serde_json::to_value(&canonical).unwrap(); - json["nextArtifacts"][0] = - serde_json::to_value(finding_request(logical_id, SccmRole::Client, reason)).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}")); } @@ -4561,6 +5611,69 @@ fn key_profile_and_extraction_result_have_deterministic_json_round_trips() { ); } +#[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 = " Date: Sat, 1 Aug 2026 23:36:50 -0400 Subject: [PATCH 258/422] feat(sccm): assess pure client intake coverage (#394) * test(sccm): define pure client intake contract * feat(sccm): define pure client intake coverage * test(sccm): harden client intake provenance boundaries * feat(sccm): validate client intake provenance * test(sccm): reject unsafe client intake provenance RED: cargo test --locked -p cmtraceopen-parser --test sccm_client_intake -- --nocapture ran 13 tests: 11 passed and 2 intended privacy regressions failed. * fix(sccm): bound client intake provenance paths * test(sccm): pin bounded intake path namespaces REVIEW RED: focused client intake ran 13 tests: 12 passed and the identity-slot bypass regression failed. * fix(sccm): allowlist client intake path namespaces * test(sccm): reject impossible intake timestamp paths CODERABBIT RED: focused client intake ran 13 tests: 12 passed and the impossible timestamp regression failed. * fix(sccm): validate intake timestamp path values * test(sccm): reject unnamespaced path fingerprints * fix(sccm): namespace client path fingerprints * test(sccm): reject identity-bearing synthetic handles * fix(sccm): bound synthetic fingerprint vocabulary * test(sccm): reject numeric synthetic identities * fix(sccm): constrain synthetic numeric markers * test(sccm): reject free-form intake metadata * fix(sccm): bound intake metadata vocabularies * test(sccm): reject free-form unknown rotations * fix(sccm): require opaque unknown rotations * test(sccm): close intake identity channels * fix(sccm): bind client intake identities * test(sccm): preserve shared location rotations * fix(sccm): bind shared location rotations * test(sccm): tighten client intake review regressions * fix(sccm): tighten client path fingerprint grammar * test(sccm): reject complete capped client fragments * fix(sccm): fail closed on complete capped fragments * test(sccm): expose escaped client path probe gap * test(sccm): harden client intake privacy probes * test(sccm): expose client intake coverage contradictions The client intake contract accepts three contradictions. A Captured fragment admitting fragmentComplete=false passes while the Capped mirror is rejected. A mixed group with one captured fragment and one Absent sibling marker reports group coverage Absent with a gap claiming no artifact was supplied, while the same group still serializes the captured fragment, because coverage_rank lets marker states outrank Captured and no test pins mixed-state groups. Two Absent markers for the same source under distinct caller labels are both accepted and project as two fragments because canonical-identity dedup only covers physical states. Add failing tests pinning the intended semantics: Captured plus incomplete fails closed via InvalidFragmentCompleteness, mixed groups keep the physical coverage and emit per-source gaps that name the absent or denied source, a capped mix keeps both the group gap and the per-source gap, and duplicate non-physical marker identities fail closed via DuplicateArtifactId. Refs #319 * fix(sccm): keep mixed-group client captures coherent Before this change the client intake accepted a Captured fragment that admitted fragmentComplete=false, let coverage_rank place Absent above Captured so a mixed group diagnosed itself as unsupplied while serializing the captured fragment, and let two non-physical markers double-declare the same source under distinct caller labels. Reject Captured plus incomplete via InvalidFragmentCompleteness, mirroring the Capped plus complete rule, since Capped exists to represent an incomplete capture. Compute group coverage from the physical fragments whenever any exist and emit an explicit per-source gap for each sibling marker that names the affected source, so absence stays a diagnosis without erasing captured evidence. Extend canonical-identity dedup to non-physical markers keyed on basename, rotation, and optional path fingerprint, mirroring the duplicate-identity idiom the server lane uses for physical states; markers distinguished by explicit fingerprints remain distinct sources. Also document that physicalArtifacts intentionally includes non-physical markers and that markers may carry a path fingerprint, both flagged by review as worth stating as intended. Refs #319 * test(sccm): probe forward-slash user-root leak detection The serialized privacy probe only matches the JSON-escaped backslash form c:\\users, so a forward-slash normalized leak such as c:/users/... with the RealUser sentinel stripped passes every serialized privacy assertion undetected. Add a failing meta-test that feeds the probe a forward-slash normalized Windows user path and requires detection, per the non-blocking hardening in the exact-head review. Refs #319 * fix(sccm): match normalized user roots in privacy probe The probe helper only recognized the JSON-escaped c:\\users form, leaving forward-slash normalized Windows user paths invisible to every serialized privacy assertion when the RealUser sentinel is absent. Also match the normalized c:/users form so either separator style trips the probe, closing the review's non-blocking hardening gap. Refs #319 * test(sccm): expose marker collisions with physical sources A fingerprint-less absent marker for a source that is also physically declared is accepted, and the assessment then claims no artifact for the source was supplied while serializing the captured fragment, the exact self-contradiction class round 6 blocked on. The same root lets an unpinned marker and a fingerprint-pinned marker double-declare one source, and lets a pinned marker contradict physical evidence for its own source. Add failing tests requiring the canonical source identity, basename plus rotation, to intersect across all declarations: a non-physical marker never shares basename and rotation with a physical declaration regardless of fingerprints, an unpinned marker collides with any other declaration for its source, and both directions hold for either declaration order. Pin the accepted direction, a marker for a genuinely distinct rotation alongside the capture, and pin the intentionally two-valued ParseFailed fragment completeness the review listed as the last unpinned matrix cell. Document the client/server fingerprint asymmetry as a follow-up. Refs #319 * fix(sccm): intersect source identity across declarations The marker identity set keyed on basename, rotation, and the optional path fingerprint never intersected physical declarations, so a fingerprint-less absent marker for a captured source was accepted and the assessment asserted a coverage gap its own fragments array disproved. The same root let an unpinned marker and a fingerprint-pinned marker double-declare one source, and let a pinned marker contradict physical evidence for its own source whenever the fingerprints differed. Track the canonical source identity, casefolded basename plus rotation discriminator, for every declaration and intersect it across all declaration shapes. A non-physical marker sharing its source identity with a physical declaration fails closed with CollidingPhysicalIdentity in either declaration order, regardless of fingerprints, because physical evidence disproves any absent, denied, or skipped claim about the same source. A fingerprint-less marker collides with any other declaration for its source via DuplicateArtifactId, while markers pinned to distinct roots by distinct fingerprints stay distinct sources when no physical declaration exists. Physical declarations keep their existing fingerprint and relative-path dedup, so distinct captured rotations and root collisions stay representable. The sibling server intake closes this class by making the path fingerprint mandatory everywhere; the client keeps optional marker fingerprints for the committed all-absent fixtures, with convergence documented as a follow-up in the intake tests. Refs #319 * test(sccm): pin exact client intake error variants Ten assertions checked only is_err(). SccmClientIntakeError has 14 variants, so each passed when any validator rejected the bundle, including for a reason unrelated to the behavior under test. Each now compares against the exact expected variant. Pinning exposed two inputs that rejected for the wrong reason: malformed_rotation_and_public_provenance_values_fail_closed declared SccmRotation::Timestamped("2026-bad") to exercise the rotation grammar, but synthetic_artifact derived an "unknown" group segment from the rotated basename. The bundle failed on InvalidRelativePath before reaching the rotation check, so the rotation contract was never exercised. The artifact now carries a consistent relative path and the malformed timestamp is the only contract it violates. The rotation grammar itself lives in the SccmRotation Serialize impl, which validate_bundle reaches through serde_json::to_value. fragment_completeness_and_every_path_fingerprint_are_explicit_and_ unambiguous asserted that an AccessDenied marker with no relative path fails its provenance contract. AccessDenied is not a physical state, so MissingPhysicalProvenance is unreachable for that input; the marker actually fails on InvalidFragmentCompleteness because it retains the default fragmentComplete=true. The assertion now pins that variant, and a new physical case with its relative path stripped covers MissingPhysicalProvenance directly. Refs #319 * refactor(sccm): own the sha256 digest width in one helper is_lowercase_hex_handle served both 16-character root handles and 64-character SHA-256 digests, so every digest caller had to restate its own length guard. Four call sites repeated it, and a new caller that omitted it would silently accept a 16-character handle. is_sha256_digest now owns the digest width and the four digest callers use it. is_lowercase_hex_handle keeps its 16-or-64 root grammar unchanged; narrowing the root contract would be a behavior change, not a refactor. Both delegate to a shared is_lowercase_hex. Refs #319 * test(sccm): casefold every serialized leak assertion unsupported_physical_artifacts_retain_safe_provenance_without_raw_ host_or_path compared against the original-case serialized JSON, so a projection that normalized case would leak RealUser or real-user-host and still pass. It also used bare substring checks, which miss the JSON-escaped and forward-slash normalized Windows user roots that carry no sentinel. Swept every serialized-leak assertion in the file. Two sites exist. The first already casefolded and routed through serialized_json_contains_windows_user_root; this commit brings the second to the same shape. Positive assertions keep their exact case on purpose, with a comment saying why, because they pin what the projection must reproduce verbatim. This class has now surfaced twice, so the contract is enforced rather than documented: the helper carries a doc comment stating the rule and a debug_assert that rejects a caller passing original-case input. Verified the guard fires by temporarily feeding it the uncased string. Siblings were checked and are not this class. sccm_client_intake_fixture_contract asserts a fixture lacks a [cut] marker, and sccm_spine_contract asserts redaction of parsed message strings, a surface that must retain C:\Windows\CCM\Logs verbatim. Refs #319 * fix(sccm): preserve partial client intake coverage * fix(sccm): repair client intake contracts (#432) * fix(sccm): repair client intake contracts (#319) * fix(sccm): bind multi-dot catalog requests (#319) * fix(sccm): bind and order client source lineages (#319) * fix(sccm): align client setup catalog identity (#319) * test(sccm): construct invalid requests without serialization * test(sccm): expose client intake wire gaps * fix(sccm): validate client intake wire projections * docs(sccm): align client intake delivery state * test(sccm): expose incomplete client intake oracle * test(sccm): bind complete client intake fixture oracle * docs(sccm): enumerate client coverage states * test(sccm): separate parse failure from fragment bounds * fix(sccm): distinguish parse failure from fragment bounds * docs(sccm): close client intake review nits * docs(sccm): clarify client intake fixture contracts * fix(sccm): stabilize client intake wire shape --- crates/cmtraceopen-parser/src/sccm/catalog.rs | 189 +- .../src/sccm/client/intake.rs | 1459 ++++++++++ .../cmtraceopen-parser/src/sccm/client/mod.rs | 8 + .../cmtraceopen-parser/src/sccm/findings.rs | 29 +- crates/cmtraceopen-parser/src/sccm/mod.rs | 2 + .../tests/fixtures/sccm/client/README.md | 40 +- .../client/intake/access-denied/expected.json | 200 +- .../sccm/client/intake/capped/expected.json | 202 +- .../client/intake/collision/expected.json | 233 +- .../sccm/client/intake/complete/expected.json | 367 ++- .../client/intake/missing-root/expected.json | 352 ++- .../client/intake/rotations/expected.json | 228 +- .../client/intake/rotations/manifest.json | 6 +- .../tests/sccm_client_intake.rs | 2479 +++++++++++++++++ .../tests/sccm_spine_contract.rs | 205 +- .../preparation/issue-319-client-intake.md | 76 +- 16 files changed, 5908 insertions(+), 167 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/client/intake.rs create mode 100644 crates/cmtraceopen-parser/src/sccm/client/mod.rs create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_intake.rs diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs index 370a4fee5..127df01f5 100644 --- a/crates/cmtraceopen-parser/src/sccm/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -107,13 +107,156 @@ struct CatalogSpec { 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"], + }, + 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: "ReportingEvents.log", + logical_artifact_ids: &["client-windows-update-supplemental"], + }, +]; + +pub(crate) fn declared_client_source_memberships() -> &'static [SccmClientSourceMembership] { + CLIENT_SOURCE_MEMBERSHIPS +} + const SOURCE_CATALOG: &[CatalogSpec] = &[ CatalogSpec { - basename: "CCMSetup", + 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", @@ -174,6 +317,30 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ 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", @@ -216,6 +383,12 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ role: SccmRole::Client, family: SccmArtifactFamily::ClientApplication, }, + CatalogSpec { + basename: "ExecMgr", + logical_name: "execMgr", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientApplication, + }, CatalogSpec { basename: "ScanAgent", logical_name: "scanAgent", @@ -246,6 +419,12 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ role: SccmRole::Client, family: SccmArtifactFamily::ClientUpdates, }, + CatalogSpec { + basename: "ReportingEvents", + logical_name: "reportingEvents", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientUpdates, + }, CatalogSpec { basename: "smsts", logical_name: "smsts", @@ -411,7 +590,7 @@ pub fn classify_artifact_name(name: &str, role: SccmRole) -> SccmSourceCatalogEn role, family: entry.family.clone(), rotation: parsed.rotation, - uses_ccm_records: true, + uses_ccm_records: catalog_entry_uses_ccm_records(entry), supported_for_diagnosis: parsed.rotation_supported, }; } @@ -445,11 +624,15 @@ fn declared_catalog_entry(entry: &CatalogSpec, role: SccmRole) -> SccmSourceCata role, family: entry.family.clone(), rotation: SccmRotation::Current, - uses_ccm_records: true, + 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") +} + struct ParsedArtifactName<'a> { basename: &'a str, rotation: SccmRotation, 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..b1851c330 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -0,0 +1,1459 @@ +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::{DateTime, SecondsFormat, Utc}; +use serde::{de::Error as _, ser::Error as _, 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, +}; + +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, + Updates, +} + +#[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, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SccmClientIntakeBundle { + pub artifacts: Vec, +} + +#[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, +} + +#[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, + 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, +} + +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, + } + } +} + +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(), + } + } +} + +#[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, + coverage_gaps: Vec, +} + +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(), + 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(), + 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 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, +} + +#[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 UPDATES: &[SccmClientWorkflow] = &[SccmClientWorkflow::Updates]; +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-content", + workflows: DEPLOYMENT, + 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-location", + workflows: HEALTH_DEPLOYMENT, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-policy-agent", + workflows: POLICY, + requiredness: SccmClientSourceRequiredness::Required, + }, + ClientSourceGroupSpec { + logical_artifact_id: "client-policy-state", + workflows: POLICY, + 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(); + + 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()); + } + } + + 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 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 coverage = group_coverage(&fragments); + if fragments.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, + }); + } + } + } + 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, + 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 }) + .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, + } +} + +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, + } +} + +fn validate_bundle(bundle: &SccmClientIntakeBundle) -> Result<(), SccmClientIntakeError> { + 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)?; + 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); + } + } + } + + 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() +} + +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(), + } +} + +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]) -> SccmCoverageState { + fragments + .iter() + .map(|fragment| fragment.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, + } +} + +/// 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_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 + ) +} + +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()) +} + +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; + }; + + 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_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/mod.rs b/crates/cmtraceopen-parser/src/sccm/client/mod.rs new file mode 100644 index 000000000..dd43bd011 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -0,0 +1,8 @@ +//! Pure SCCM client diagnostics. +//! +//! This module accepts already-supplied metadata. Native discovery and capture +//! remain outside `cmtraceopen-parser`. + +mod intake; + +pub use intake::*; diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index c38d16497..368a972c8 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -2320,7 +2320,8 @@ fn requested_artifact_identity_ranges( requested_basename: &str, requested_logical_id: &str, ) -> Vec<(usize, usize)> { - let basename = normalize_catalog_identity(catalog_log_stem(requested_basename)); + 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() @@ -2329,11 +2330,37 @@ fn requested_artifact_identity_ranges( .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 } diff --git a/crates/cmtraceopen-parser/src/sccm/mod.rs b/crates/cmtraceopen-parser/src/sccm/mod.rs index 0535c8a06..88c9c975b 100644 --- a/crates/cmtraceopen-parser/src/sccm/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/mod.rs @@ -1,4 +1,5 @@ pub mod catalog; +pub mod client; mod evidence; mod findings; mod ingest; @@ -9,6 +10,7 @@ pub mod server; mod signals; pub use catalog::*; +pub use client::*; pub use findings::*; pub use ingest::*; pub use keys::*; diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md index 7273c0901..88a331bce 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/README.md @@ -1,10 +1,12 @@ # Synthetic SCCM client intake fixtures -These are preparation-only fixtures for issue #319. They are not accepted by a -production SCCM reader until #318 publishes its stable public contracts. -`manifest.json` and `expected.json` are proposed contract inputs/outputs, not -compiled test fixtures. Every identity, path, timestamp, byte count, UUID, and -log record is deterministic and synthetic. +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 @@ -24,14 +26,21 @@ 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 test designs in -`docs/sccm/preparation/issue-319-client-intake.md`, pending #318 contracts. +legacy generic-manifest mapping are intentionally documented native test +designs in `docs/sccm/preparation/issue-319-client-intake.md` and remain +pending. -Expected arrays are stable-sorted by `logicalArtifactId`. `contractState` must -remain `proposedPending318` until an implementation maps this design to the -published spine schema. Replay after #318: deserialize via its public reader, -assert coverage directly, reorder artifacts and compare normalized output, and -never interpret capped/split fragment text as a phase or terminal diagnosis. +`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 @@ -39,8 +48,11 @@ 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 -`artifactProvenance`. Noncapture artifacts remain `bytesCopied: 0` with a null -relative path and do not invent capture provenance. An applied cap counts +`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 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 index c270457e5..62f9a3dbb 100644 --- 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 @@ -1,9 +1,197 @@ { - "contractState": "proposedPending318", + "contractState": "pureIntakeImplementedNativePending", "scenario": "access-denied", - "workflowDiagnosisExpected": false, - "coverage": [{"logicalArtifactId":"client-policy-agent","state":"accessDenied"},{"logicalArtifactId":"client-policy-state","state":"captured"}], - "artifactProvenance": [{"artifactId":"fixture-access-policy-state-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}], - "requests": [{"logicalArtifactId":"client-policy-agent","reason":"Access must be provided for the bounded policy-agent source group."}], - "prohibitedClaims": ["policy failed", "management point failure"] + "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-content", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-evaluation", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-identity", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-location", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-agent", + "coverage": "accessDenied", + "fragmentArtifactIds": [ + "fixture-access-policy-agent-root-a-current" + ] + }, + { + "logicalArtifactId": "client-policy-state", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-access-policy-state-root-a-current" + ] + }, + { + "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-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-location", + "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-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/capped/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json index 9974c2932..75ba507ff 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json @@ -1,13 +1,195 @@ { - "contractState": "proposedPending318", + "contractState": "pureIntakeImplementedNativePending", "scenario": "capped", - "workflowDiagnosisExpected": false, - "coverage": [{"logicalArtifactId":"client-content","state":"capped","fragmentComplete":false,"truncated":true}], - "artifactProvenance": [{"artifactId":"fixture-capped-content-root-a-current","encoding":"utf-8","byteLimit":128,"limitApplied":true,"bytesCopied":128,"sha256":"3253f6c4bc7d74bd2dbdadbe2f6543ff61a57161f85f650869b1292556270114"}], - "rawByteCountedBeforeDecoding": true, - "exactSourcePrefix": true, - "collectorInjectedMarker": false, - "logicalRecordParseable": false, - "requests": [{"logicalArtifactId":"client-content","reason":"Recapture the bounded content group with sufficient approved limits."}], - "prohibitedClaims": ["content transfer terminal failure", "distribution point failure"] + "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-content", + "coverage": "capped", + "fragmentArtifactIds": [ + "fixture-capped-content-root-a-current" + ] + }, + { + "logicalArtifactId": "client-evaluation", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-identity", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-location", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-agent", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-state", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-updates", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-windows-update-supplemental", + "coverage": "absent", + "fragmentArtifactIds": [] + } + ], + "fragments": [ + { + "artifactId": "fixture-capped-content-root-a-current", + "basename": "DataTransferService.log", + "rotation": { + "kind": "current" + }, + "coverage": "capped", + "pathFingerprint": "synthetic-content-capped", + "relativePath": "evidence/client-content/current/DataTransferService.log", + "fragmentComplete": false, + "configmgrVersion": "5.00.TEST.0000", + "collectedAtUtc": "2026-07-30T00:03:00Z", + "encoding": "utf-8" + } + ], + "physicalArtifactIds": [ + "fixture-capped-content-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-content", + "artifactId": "fixture-capped-content-root-a-current", + "role": "client", + "coverage": "capped", + "reason": "Client source DataTransferService.log reached its capture limit." + }, + { + "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-location", + "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-policy-state", + "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-capped-content-root-a-current", + "byteLimit": 128, + "limitApplied": true, + "bytesCopied": 128, + "sha256": "3253f6c4bc7d74bd2dbdadbe2f6543ff61a57161f85f650869b1292556270114" + } + ], + "captureAssertions": { + "truncated": true, + "rawByteCountedBeforeDecoding": true, + "exactSourcePrefix": true, + "collectorInjectedMarker": false + } + }, + "downstreamDesignPending": { + "workflowDiagnosisExpected": false, + "requests": [ + { + "logicalArtifactId": "client-content", + "reason": "Recapture the bounded content group with sufficient approved limits." + } + ], + "prohibitedClaims": [ + "content transfer terminal failure", + "distribution point failure" + ] + } } 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 index 3a6015dfe..472e85897 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/expected.json @@ -1,46 +1,195 @@ { - "contractState": "proposedPending318", + "contractState": "pureIntakeImplementedNativePending", "scenario": "collision", - "workflowDiagnosisExpected": false, - "coverage": [ - { - "logicalArtifactId": "client-app-enforce", - "state": "captured", - "physicalArtifactCount": 2 - } - ], - "artifactProvenance": [ - { - "artifactId": "fixture-collision-app-enforce-root-a-current", - "encoding": "utf-8", - "byteLimit": 4096, - "limitApplied": false - }, - { - "artifactId": "fixture-collision-app-enforce-root-b-current", - "encoding": "utf-8", - "byteLimit": 4096, - "limitApplied": false - } - ], - "preservedPhysicalArtifacts": [ - { - "artifactId": "fixture-collision-app-enforce-root-a-current", - "pathFingerprint": "synthetic-collision-root-a", - "relativePath": "evidence/client-app-enforce/root-a/current/AppEnforce.log" - }, - { - "artifactId": "fixture-collision-app-enforce-root-b-current", - "pathFingerprint": "synthetic-collision-root-b", - "relativePath": "evidence/client-app-enforce/root-b/current/AppEnforce.log" - } - ], - "assertions": { - "distinctArtifactIds": true, - "distinctPathFingerprints": true, - "distinctRelativePaths": true, - "mergedByBasename": false, - "overwrittenByBasename": false + "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-content", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-evaluation", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-identity", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-location", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-agent", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-state", + "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-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-location", + "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-policy-state", + "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." + } + ] }, - "requests": [] + "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/complete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json index 29d6a8141..bc7f6f3c0 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json @@ -1,40 +1,333 @@ { - "contractState": "proposedPending318", + "contractState": "pureIntakeImplementedNativePending", "scenario": "complete", - "workflowDiagnosisExpected": false, - "coverage": [ - {"logicalArtifactId":"client-app-enforce","state":"captured"}, - {"logicalArtifactId":"client-app-intent","state":"captured"}, - {"logicalArtifactId":"client-ccmsetup","state":"captured"}, - {"logicalArtifactId":"client-content","state":"captured"}, - {"logicalArtifactId":"client-evaluation","state":"captured"}, - {"logicalArtifactId":"client-identity","state":"captured"}, - {"logicalArtifactId":"client-location","state":"captured"}, - {"logicalArtifactId":"client-policy-agent","state":"captured"}, - {"logicalArtifactId":"client-policy-state","state":"captured"}, - {"logicalArtifactId":"client-updates","state":"captured"}, - {"logicalArtifactId":"client-windows-update-supplemental","state":"captured"} - ], - "artifactProvenance": [ - {"artifactId":"fixture-complete-app-enforce-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, - {"artifactId":"fixture-complete-app-intent-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, - {"artifactId":"fixture-complete-ccmsetup-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, - {"artifactId":"fixture-complete-content-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, - {"artifactId":"fixture-complete-evaluation-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, - {"artifactId":"fixture-complete-identity-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, - {"artifactId":"fixture-complete-location-services-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, - {"artifactId":"fixture-complete-policy-agent-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, - {"artifactId":"fixture-complete-policy-state-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, - {"artifactId":"fixture-complete-updates-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, - {"artifactId":"fixture-complete-update-supplemental-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false} - ], - "physicalArtifactAssertions": [ - { - "artifactId": "fixture-complete-location-services-root-a-current", - "catalogEntryId": "client-location-services-shared", - "groupMemberships": ["client-content", "client-location"], - "physicalCaptureCount": 1 - } - ], - "requests": [] + "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-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-location", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-location-services-root-a-current" + ] + }, + { + "logicalArtifactId": "client-policy-agent", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-policy-agent-root-a-current" + ] + }, + { + "logicalArtifactId": "client-policy-state", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-policy-state-root-a-current" + ] + }, + { + "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-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-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" + } + ], + "physicalArtifactIds": [ + "fixture-complete-app-enforce-root-a-current", + "fixture-complete-app-intent-root-a-current", + "fixture-complete-ccmsetup-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" + ], + "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-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-update-supplemental-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 86 + }, + { + "artifactId": "fixture-complete-updates-root-a-current", + "byteLimit": 4096, + "limitApplied": false, + "bytesCopied": 174 + } + ] + }, + "downstreamDesignPending": { + "workflowDiagnosisExpected": false, + "requests": [], + "prohibitedClaims": [] + } } 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 index 27bb11d24..9e7bfcf08 100644 --- 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 @@ -1,10 +1,348 @@ { - "contractState": "proposedPending318", + "contractState": "pureIntakeImplementedNativePending", "scenario": "missing-root", - "workflowDiagnosisExpected": false, - "coverage": [ - {"logicalArtifactId":"client-app-enforce","state":"absent"},{"logicalArtifactId":"client-app-intent","state":"absent"},{"logicalArtifactId":"client-ccmsetup","state":"absent"},{"logicalArtifactId":"client-content","state":"absent"},{"logicalArtifactId":"client-evaluation","state":"absent"},{"logicalArtifactId":"client-identity","state":"absent"},{"logicalArtifactId":"client-location","state":"absent"},{"logicalArtifactId":"client-policy-agent","state":"absent"},{"logicalArtifactId":"client-policy-state","state":"absent"},{"logicalArtifactId":"client-updates","state":"absent"},{"logicalArtifactId":"client-windows-update-supplemental","state":"absent"} - ], - "requests": [{"kind":"intakeCoverage","reason":"No configured client root was discovered."}], - "prohibitedClaims": ["client not installed", "client healthy", "client failing"] + "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-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-location", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-location-services-current" + ] + }, + { + "logicalArtifactId": "client-policy-agent", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-policy-agent-current" + ] + }, + { + "logicalArtifactId": "client-policy-state", + "coverage": "absent", + "fragmentArtifactIds": [ + "fixture-missing-policy-state-current" + ] + }, + { + "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-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-location", + "artifactId": "fixture-missing-location-services-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source LocationServices.log 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-policy-state", + "artifactId": "fixture-missing-policy-state-current", + "role": "client", + "coverage": "absent", + "reason": "No artifact for client source CIAgent.log 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/rotations/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json index 008b072db..44b76df0d 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json @@ -1,13 +1,221 @@ { - "contractState": "proposedPending318", + "contractState": "pureIntakeImplementedNativePending", "scenario": "rotations", - "workflowDiagnosisExpected": false, - "coverage": [{"logicalArtifactId":"client-app-enforce","state":"captured","fragmentCount":3,"rotationOrder":["current","lo","numbered:2"],"distinctPathFingerprints":3}], - "artifactProvenance": [ - {"artifactId":"fixture-rotations-app-enforce-root-a-current","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, - {"artifactId":"fixture-rotations-app-enforce-root-a-lo","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, - {"artifactId":"fixture-rotations-app-enforce-root-b-numbered-2","encoding":"utf-8","byteLimit":4096,"limitApplied":false} - ], - "requests": [], - "assertions": ["Each rotation fragment retains its physical artifact ID and path fingerprint.", "No phase, key, or finding is inferred from rotation grouping alone."] + "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-content", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-evaluation", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-identity", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-location", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-agent", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-policy-state", + "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-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-location", + "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-policy-state", + "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 index 8bab45780..b4505d00b 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/manifest.json @@ -4,8 +4,8 @@ "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-current","rotation":{"kind":"current","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-lo","rotation":{"kind":"lo","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-b-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-b/CCM/Logs/AppEnforce.log.2","pathFingerprint":"synthetic-root-b-numbered-2","rotation":{"kind":"numbered","number":2,"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"} + {"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/sccm_client_intake.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs new file mode 100644 index 000000000..91c287e5a --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs @@ -0,0 +1,2479 @@ +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, + SccmClientIntakeCoverageGap, SccmClientIntakeError, SccmClientIntakeFragment, + SccmClientUnsupportedArtifact, SccmCoverageState, SccmRole, SccmRotation, SccmUnknownRotation, +}; +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, + } + }) + .collect(), + } +} + +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), + } +} + +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 +} + +/// 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_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], + }) + .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"), + "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(), 11); + 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], + }; + 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(), + 12, + "the shared LocationServices declaration contributes one gap to each consumer group" + ); + 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), + }; + + assert_eq!( + assess_client_intake(&SccmClientIntakeBundle { + artifacts: vec![contradictory], + }), + 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], + }) + .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], + }) + .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], + }) + .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], + }) + .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], + }) + .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], + }), + 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], + }), + 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], + }) + .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()], + }), + 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], + }), + 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()], + }) + .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], + }) + .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], + }) + .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], + }), + 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()], + }), + 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], + }), + 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], + }) + .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_"), + ], + }; + 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], + }), + 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], + }), + 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], + }), + 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], + }), + 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], + }) + .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], + }), + 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], + }) + .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], + }), + 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], + }), + 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], + }), + 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], + }) + .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], + }) + .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], + }), + 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], + }), + 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], + }), + 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], + }), + 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], + }) + .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], + }) + .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], + }) + .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], + }), + 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], + }), + 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], + }), + 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], + }), + 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], + }), + 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], + }) + .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], + }); + 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], + }), + 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], + }) + .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], + }), + 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], + }), + 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], + }) + .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], + }); + 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], + }), + 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], + }) + .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], + }) + .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], + }), + 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], + }) + .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], + }) + .unwrap_or_else(|error| panic!("approved path {relative_path:?} failed: {error}")); + } +} diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 933c47126..1caf44cbf 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -3840,6 +3840,153 @@ fn finding_review_every_exact_catalog_identity_passes_at_every_public_boundary() ); } +#[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."; @@ -6913,13 +7060,21 @@ type ExpectedCatalogTuple = ( fn expected_catalog_tuples() -> Vec { vec![ ( - "CCMSetup.log", + "ccmsetup.log", SccmRole::Client, "ccmSetup", SccmArtifactFamily::ClientSetup, true, true, ), + ( + "client.msi.log", + SccmRole::Client, + "clientMsi", + SccmArtifactFamily::ClientSetup, + false, + true, + ), ( "CcmEval.log", SccmRole::Client, @@ -7000,6 +7155,38 @@ fn expected_catalog_tuples() -> Vec { 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, @@ -7056,6 +7243,14 @@ fn expected_catalog_tuples() -> Vec { true, true, ), + ( + "ExecMgr.log", + SccmRole::Client, + "execMgr", + SccmArtifactFamily::ClientApplication, + true, + true, + ), ( "ScanAgent.log", SccmRole::Client, @@ -7096,6 +7291,14 @@ fn expected_catalog_tuples() -> Vec { true, true, ), + ( + "ReportingEvents.log", + SccmRole::Client, + "reportingEvents", + SccmArtifactFamily::ClientUpdates, + false, + true, + ), ( "smsts.log", SccmRole::Client, diff --git a/docs/sccm/preparation/issue-319-client-intake.md b/docs/sccm/preparation/issue-319-client-intake.md index 56da60a46..40c870aca 100644 --- a/docs/sccm/preparation/issue-319-client-intake.md +++ b/docs/sccm/preparation/issue-319-client-intake.md @@ -2,12 +2,13 @@ ## Purpose and dependency boundary -This preparation artifact defines the bounded source inventory and synthetic -fixture design for issue #319. It is deliberately not production code and does -not freeze an API, Rust type, Tauri feature, Cargo dependency, or the #318 -serialized contract. #319 may start implementation only after #318 publishes -and tests its public artifact, evidence, coverage, signal, key, timestamp, -redaction, finding, and bundle-reader contracts. +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 @@ -139,7 +140,10 @@ filesystem access, globbing in the pure crate, or redefinition of CCM. - Sort manifest artifacts by catalog entry ID, normalized path fingerprint, rotation rank, original basename, then physical artifact ID. Group memberships - are sorted independently. Expected coverage arrays use stable logical IDs. + 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. @@ -175,15 +179,16 @@ filesystem access, globbing in the pure crate, or redefinition of CCM. 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 #318 publishes compatible public types and a native -test-double boundary; they must be added before #319 reaches its exit gate. -Every production behavior must begin with a focused red test. Required pure -tests deserialize through the published #318 bundle reader, use direct field -assertions (not permissive snapshots), and cover unknown basenames, unsupported -suffixes, deterministic reordered input, source collisions, and each capture -state above. Native temp-directory tests cover caps, access provider results, -escape rejection, and legacy mapping. Windows client collection remains a -separate acceptance gate. +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 @@ -203,23 +208,28 @@ separate acceptance gate. 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` asserts explicit coverage and requests. Its `contractState` - is `proposedPending318`, so no fixture suggests that interface names, enum - spellings, or schema fields are final. - -## Exact dependency blockers - -1. #318 has not yet supplied a stable public `SccmArtifact`, evidence, - coverage, rotation, redaction, key/timestamp, finding, and bundle-reader - contract in this worktree. No parser source, test compiled against an - invented interface, or Cargo change is authorized here. -2. The final mapping from the proposed SCCM extension states to #318 coverage - variants is unresolved. In particular `accessDenied`, `capped`, `skipped`, - `unsafePath`, and `legacyUnknownDetail` must remain distinct. +- `expected.json` uses `contractState: pureIntakeImplementedNativePending`. + `pureAssessment` is the complete typed + executable oracle. `nativeDesignPending` holds byte/limit/digest facts that + remain outside the pure projection, and `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 are unresolved; only provenance-backed `collected`/`missing` may - map forward. + 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 - can validate only JSON, ordering, synthetic privacy, and later pure/native - test doubles. + proves the pure projection, JSON, ordering, synthetic privacy, and future + native test doubles only. From a48c824c529371c210c03d73b2a45cd33154e373 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 1 Aug 2026 23:41:40 -0400 Subject: [PATCH 259/422] fix(sccm): harden server intake coverage --- .../src/sccm/server/windows/intake.rs | 236 ++++++++-- .../tests/sccm_server_intake.rs | 442 +++++++++++++++++- 2 files changed, 633 insertions(+), 45 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index f69ad168c..701fb9121 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -12,6 +12,13 @@ use crate::sccm::{ use super::catalog::{classify_declared_server_source, expected_family, SccmServerSourceKind}; +/// 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; + type PathFingerprintKey = ( String, Option, @@ -67,6 +74,7 @@ pub struct SccmServerArtifactAssessment { 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, @@ -140,6 +148,8 @@ pub enum SccmServerIntakeError { 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( @@ -153,6 +163,9 @@ pub fn assess_server_intake( manifest_json: &str, payloads: &[SccmServerArtifactPayload], ) -> Result { + if manifest_json.len() > MAX_SCCM_SERVER_MANIFEST_BYTES { + return Err(SccmServerIntakeError::ManifestLimitExceeded); + } let manifest: RawServerManifest = serde_json::from_str(manifest_json) .map_err(|_| SccmServerIntakeError::MalformedManifest)?; if manifest.sccm_manifest_version != 1 { @@ -161,6 +174,7 @@ pub fn assess_server_intake( if manifest.bundle_role != "server" { return Err(SccmServerIntakeError::InvalidBundleRole); } + validate_manifest_bounds(&manifest, payloads)?; let topology = normalize_topology(&manifest)?; let mut payload_by_id = BTreeMap::new(); @@ -338,6 +352,50 @@ impl PreparedArtifact { } } +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; + for artifact in &manifest.artifacts { + if artifact.bytes_copied > MAX_SCCM_SERVER_ARTIFACT_BYTES { + return Err(SccmServerIntakeError::ManifestLimitExceeded); + } + if let Some(limit) = &artifact.collection_limit { + if limit.byte_limit > MAX_SCCM_SERVER_ARTIFACT_BYTES { + 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 normalize_topology( manifest: &RawServerManifest, ) -> Result { @@ -404,10 +462,13 @@ fn normalize_artifact( canonical_artifact_identities: &mut BTreeSet, payload_by_id: &BTreeMap<&str, &[u8]>, ) -> Result { + let unsupported_unknown = artifact.capture_state == SccmCoverageState::Unsupported + && artifact.producer_role == SccmRole::Unknown("unclassified".to_owned()); 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) + || !safe_source_id(&artifact.source_id, unsupported_unknown) + || !safe_source_kind(&artifact.source_kind, unsupported_unknown) || !safe_lineage_id(&artifact.rotation.lineage_id, synthetic_fixture) || !safe_path_fingerprint( &artifact.configured_path_provenance.path_fingerprint, @@ -427,13 +488,13 @@ fn normalize_artifact( synthetic_fixture, "subject", ) + || (!unsupported_unknown && artifact.producer_host_handle.is_none()) + || (unsupported_unknown && !safe_public_basename(&artifact.original_basename)) { return Err(SccmServerIntakeError::InvalidArtifact); } let producer_is_observed = roles_observed.contains(&artifact.producer_role); - let unsupported_unknown = artifact.capture_state == SccmCoverageState::Unsupported - && artifact.producer_role == SccmRole::Unknown("unclassified".to_owned()); if (!producer_is_observed && !unsupported_unknown) || (producer_is_observed && !is_declared_server_role(&artifact.producer_role)) { @@ -459,7 +520,7 @@ fn normalize_artifact( &artifact.original_basename, ); - let (family, original_basename, rotation, parser_eligible) = + let (family, original_basename, rotation, mut parser_eligible) = if let Some((spec, classified)) = classification { let family = expected_family(spec.source_id).ok_or(SccmServerIntakeError::InvalidArtifact)?; @@ -482,9 +543,9 @@ fn normalize_artifact( } } else if unsupported_unknown { ( - SccmArtifactFamily::Unknown("unsupported".to_owned()), - None, - None, + SccmArtifactFamily::Unknown(artifact.source_id.clone()), + Some(artifact.original_basename.clone()), + parse_retained_rotation(&artifact.rotation)?, false, ) } else { @@ -555,6 +616,11 @@ fn normalize_artifact( 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(), @@ -573,32 +639,50 @@ fn normalize_artifact( let mut state = artifact.capture_state.clone(); if artifact.capture_state == SccmCoverageState::Captured && parser_eligible { let bytes = bytes.ok_or(SccmServerIntakeError::MissingPayload)?; - if artifact.encoding.as_deref() != Some("utf-8") { - return Err(SccmServerIntakeError::InvalidPayloadEncoding); - } - let content = std::str::from_utf8(bytes) - .map_err(|_| SccmServerIntakeError::InvalidPayloadEncoding)?; - evidence = normalize_ccm_artifact( - SccmArtifact { - artifact_id: artifact.artifact_id.clone(), - display_name: original_basename - .clone() - .ok_or(SccmServerIntakeError::InvalidArtifact)?, - 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, - ); - if evidence.is_empty() { - state = SccmCoverageState::ParseFailed; + 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 { + state = SccmCoverageState::ParseFailed; + } + } else { + state = SccmCoverageState::Unsupported; + parser_eligible = false; } } @@ -611,11 +695,8 @@ fn normalize_artifact( workflow_subject_handle: artifact .workflow_subject .and_then(|subject| subject.instance_handle), - source_id: if unsupported_unknown { - "unsupported".to_owned() - } else { - artifact.source_id - }, + source_id: artifact.source_id, + source_kind: artifact.source_kind, family, original_basename, rotation, @@ -702,6 +783,43 @@ fn validate_payload_contract<'a>( 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() % 2 != 0 { + 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" => { + let (content, _, had_errors) = encoding_rs::WINDOWS_1252.decode(bytes); + if had_errors { + Err(SccmServerIntakeError::InvalidPayloadEncoding) + } else { + Ok(Some(content.into_owned())) + } + } + "unknown" => Ok(None), + _ => Err(SccmServerIntakeError::InvalidPayloadEncoding), + } +} + fn validate_relative_path( relative_path: Option, original_basename: Option<&str>, @@ -853,6 +971,15 @@ fn parse_declared_rotation( 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) +} + fn parse_configured_path_state( value: &str, ) -> Result { @@ -913,6 +1040,7 @@ fn request_for_gap( SccmCoverageState::Absent | SccmCoverageState::AccessDenied | SccmCoverageState::Capped + | SccmCoverageState::Unsupported | SccmCoverageState::ParseFailed ) { return None; @@ -1006,7 +1134,7 @@ fn safe_manifest_artifact_id(value: &str, synthetic_fixture: bool) -> bool { opaque_sha256_handle(value, "cmtraceopen.artifact.sha256.v1:") } -fn safe_source_id(value: &str) -> bool { +fn safe_source_id(value: &str, allow_unknown: bool) -> bool { matches!( value, "server-sitecomp" @@ -1017,7 +1145,27 @@ fn safe_source_id(value: &str) -> bool { | "server-dp-distribution" | "server-sup-sync" | "unknown-db-supplement" - ) + ) || (allow_unknown + && (1..=64).contains(&value.len()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + })) +} + +fn safe_source_kind(value: &str, allow_unknown: bool) -> bool { + matches!(value, "ccmLog" | "iisW3c" | "structuredSupplement") + || (allow_unknown + && (1..=64).contains(&value.len()) + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))) +} + +fn safe_public_basename(value: &str) -> bool { + (1..=255).contains(&value.len()) + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) } fn safe_lineage_id(value: &str, synthetic_fixture: bool) -> bool { @@ -1150,9 +1298,11 @@ fn coverage_sort_key(state: &SccmCoverageState) -> &'static str { fn rotation_sort_key(rotation: Option<&SccmRotation>) -> String { match rotation { - Some(SccmRotation::LoUnderscore) => "0-lo-underscore".to_owned(), - Some(SccmRotation::Numbered(value)) => format!("1-numbered-{value:010}"), - Some(SccmRotation::Timestamped(value)) => format!("2-timestamped-{value}"), + 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(), diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index bf65aaeff..0716045e3 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -45,6 +45,77 @@ fn serialize_manifest(manifest: &Value) -> String { serde_json::to_string(manifest).expect("manifest serializes") } +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 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 assert_unsafe_mutation_is_rejected( scenario: &str, marker: &str, @@ -756,9 +827,9 @@ fn server_intake_exercises_role_state_rotation_and_privacy_matrix() { .map(|artifact| artifact.rotation.clone()) .collect::>(), vec![ - Some(SccmRotation::LoUnderscore), - Some(SccmRotation::Numbered(2)), Some(SccmRotation::Timestamped("20260729-235700".to_owned())), + Some(SccmRotation::Numbered(2)), + Some(SccmRotation::LoUnderscore), Some(SccmRotation::Current), ] ); @@ -788,3 +859,370 @@ fn server_intake_exercises_role_state_rotation_and_privacy_matrix() { ] ); } + +#[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>(); + scenarios.sort(); + + for scenario in 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" + ); + + for key in [ + "canonicalArtifactIds", + "canonicalRotationArtifactIds", + "retainedUnclassifiedArtifactIds", + ] { + if let Some(expected_ids) = expected.get(key) { + let actual_ids = Value::Array( + actual["artifacts"] + .as_array() + .expect("assessment artifacts are an array") + .iter() + .map(|artifact| artifact["artifactId"].clone()) + .collect(), + ); + assert_eq!(actual_ids, *expected_ids, "{scenario}: {key}"); + } + } + + 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 in ["producerRole", "workflowSubjectRole", "sourceId", "state"] { + if let Some(expected_value) = expected_row.get(key) { + assert_eq!( + &actual_row[key], expected_value, + "{scenario}: coverage {key}" + ); + } + } + } + + 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, actual_value) in [ + ( + "encoding", + &actual_artifact["captureProvenance"]["encoding"], + ), + ( + "byteLimit", + &actual_artifact["captureProvenance"]["byteLimit"], + ), + ( + "limitApplied", + &actual_artifact["captureProvenance"]["limitApplied"], + ), + ("bytesCopied", &actual_artifact["bytesCopied"]), + ("relativePath", &actual_artifact["relativePath"]), + ] { + if let Some(expected_value) = expected_artifact.get(expected_key) { + 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" + ); + if let Some(line_range) = expected_row.get("lineRange") { + assert_eq!(records[0]["reference"]["lineStart"], line_range["start"]); + assert_eq!(records[0]["reference"]["lineEnd"], line_range["end"]); + } + } + } + + 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); + } + } +} From e6087171465aad549e6494441265ca867feb982d Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 00:11:46 -0400 Subject: [PATCH 260/422] fix(sccm): harden server intake contract --- crates/cmtraceopen-parser/Cargo.toml | 2 +- .../src/sccm/server/windows/intake.rs | 199 +++++- .../expected.json | 7 +- .../intake/unsorted-manifest/expected.json | 2 +- .../tests/sccm_server_intake.rs | 583 +++++++++++++++++- 5 files changed, 727 insertions(+), 66 deletions(-) diff --git a/crates/cmtraceopen-parser/Cargo.toml b/crates/cmtraceopen-parser/Cargo.toml index 51f826871..192d40cf0 100644 --- a/crates/cmtraceopen-parser/Cargo.toml +++ b/crates/cmtraceopen-parser/Cargo.toml @@ -26,6 +26,6 @@ encoding_rs = "0.8" log = "0.4" thiserror = "2" base64 = "0.22" +sha2 = "0.11" [dev-dependencies] -sha2 = "0.11" diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 701fb9121..119c9faba 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use chrono::{DateTime, SecondsFormat, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sha2::{Digest, Sha256}; use thiserror::Error; use crate::sccm::{ @@ -88,6 +89,9 @@ pub struct SccmServerArtifactAssessment { pub collected_at_utc: String, pub relative_path: Option, pub bytes_copied: u64, + pub content_sha256: Option, + pub truncated: Option, + pub fragment_complete: Option, pub capture_provenance: Option, pub parser_eligible: bool, } @@ -174,6 +178,7 @@ pub fn assess_server_intake( if manifest.bundle_role != "server" { return Err(SccmServerIntakeError::InvalidBundleRole); } + validate_manifest_metadata(&manifest)?; validate_manifest_bounds(&manifest, payloads)?; let topology = normalize_topology(&manifest)?; @@ -396,6 +401,33 @@ fn validate_manifest_bounds( Ok(()) } +fn validate_manifest_metadata(manifest: &RawServerManifest) -> Result<(), SccmServerIntakeError> { + if manifest.synthetic_fixture { + let privacy = manifest + .privacy + .as_ref() + .ok_or(SccmServerIntakeError::InvalidArtifact)?; + if manifest.proposal_only != Some(true) + || !privacy.synthetic + || privacy.raw_paths != "redacted" + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + } else if manifest.proposal_only == Some(true) + || manifest + .privacy + .as_ref() + .is_some_and(|privacy| privacy.synthetic || privacy.raw_paths != "redacted") + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + + if manifest.input_order_is_deliberately_unsorted == Some(false) { + return Err(SccmServerIntakeError::InvalidArtifact); + } + Ok(()) +} + fn normalize_topology( manifest: &RawServerManifest, ) -> Result { @@ -464,11 +496,16 @@ fn normalize_artifact( ) -> Result { let unsupported_unknown = artifact.capture_state == SccmCoverageState::Unsupported && artifact.producer_role == SccmRole::Unknown("unclassified".to_owned()); + 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, unsupported_unknown) - || !safe_source_kind(&artifact.source_kind, unsupported_unknown) + || !safe_source_id(&artifact.source_id, unsupported_unknown, synthetic_fixture) + || !safe_source_kind( + &artifact.source_kind, + unsupported_unknown, + synthetic_fixture, + ) || !safe_lineage_id(&artifact.rotation.lineage_id, synthetic_fixture) || !safe_path_fingerprint( &artifact.configured_path_provenance.path_fingerprint, @@ -489,7 +526,8 @@ fn normalize_artifact( "subject", ) || (!unsupported_unknown && artifact.producer_host_handle.is_none()) - || (unsupported_unknown && !safe_public_basename(&artifact.original_basename)) + || (unsupported_unknown + && !safe_public_basename(&artifact.original_basename, synthetic_fixture)) { return Err(SccmServerIntakeError::InvalidArtifact); } @@ -631,6 +669,7 @@ fn normalize_artifact( )?; let (bytes, capture_provenance) = validate_payload_contract(&artifact, relative_path.as_deref(), payload_by_id)?; + 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)); @@ -710,6 +749,9 @@ fn normalize_artifact( collected_at_utc, relative_path, bytes_copied: artifact.bytes_copied, + content_sha256, + truncated: artifact.truncated, + fragment_complete: artifact.fragment_complete, capture_provenance, parser_eligible, }, @@ -717,6 +759,11 @@ fn normalize_artifact( }) } +fn payload_sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + fn validate_payload_contract<'a>( artifact: &RawServerArtifact, relative_path: Option<&str>, @@ -796,7 +843,7 @@ fn decode_server_payload( } "utf-16le" => { let bytes = bytes.strip_prefix(&[0xff, 0xfe]).unwrap_or(bytes); - if bytes.len() % 2 != 0 { + if !bytes.len().is_multiple_of(2) { return Err(SccmServerIntakeError::InvalidPayloadEncoding); } let units = bytes @@ -1083,6 +1130,73 @@ fn normalize_source_version( Ok(Some(value.to_owned())) } +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(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 && value == "5.00.TEST" { return true; @@ -1134,7 +1248,7 @@ fn safe_manifest_artifact_id(value: &str, synthetic_fixture: bool) -> bool { opaque_sha256_handle(value, "cmtraceopen.artifact.sha256.v1:") } -fn safe_source_id(value: &str, allow_unknown: bool) -> bool { +fn safe_source_id(value: &str, allow_unknown: bool, synthetic_fixture: bool) -> bool { matches!( value, "server-sitecomp" @@ -1146,26 +1260,28 @@ fn safe_source_id(value: &str, allow_unknown: bool) -> bool { | "server-sup-sync" | "unknown-db-supplement" ) || (allow_unknown - && (1..=64).contains(&value.len()) - && value.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') - })) + && if synthetic_fixture { + value == "unknown-db-supplement" + } else { + opaque_sha256_handle(value, "cmtraceopen.source.sha256.v1:") + }) } -fn safe_source_kind(value: &str, allow_unknown: bool) -> bool { - matches!(value, "ccmLog" | "iisW3c" | "structuredSupplement") - || (allow_unknown - && (1..=64).contains(&value.len()) - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))) +fn safe_source_kind(value: &str, allow_unknown: bool, synthetic_fixture: bool) -> bool { + matches!( + value, + "ccmLog" | "iisW3c" | "structuredSupplement" | "unknown" + ) || (allow_unknown + && !synthetic_fixture + && opaque_sha256_handle(value, "cmtraceopen.source-kind.sha256.v1:")) } -fn safe_public_basename(value: &str) -> bool { - (1..=255).contains(&value.len()) - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +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 { @@ -1310,18 +1426,31 @@ fn rotation_sort_key(rotation: Option<&SccmRotation>) -> String { } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawServerManifest { sccm_manifest_version: u32, #[serde(default)] synthetic_fixture: bool, + #[serde(default)] + proposal_only: Option, + #[serde(default)] + privacy: Option, bundle_role: String, topology: RawServerTopology, + #[serde(default)] + input_order_is_deliberately_unsorted: Option, artifacts: Vec, } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawServerPrivacy { + synthetic: bool, + raw_paths: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawServerTopology { capture_host: String, site_code: String, @@ -1329,7 +1458,7 @@ struct RawServerTopology { } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawServerArtifact { artifact_id: String, producer_role: SccmRole, @@ -1341,24 +1470,38 @@ struct RawServerArtifact { original_path: String, original_basename: String, configured_path_provenance: RawConfiguredPathProvenance, + #[serde(default)] + default_candidate_state: Option, rotation: RawServerRotation, capture_state: SccmCoverageState, + #[serde(default)] + collection_detail: Option, + #[serde(default)] + skip_reason: Option, + #[serde(default)] + unsupported_reason: Option, encoding: Option, collection_limit: Option, + #[serde(default)] + truncated: Option, + #[serde(default)] + fragment_complete: Option, collected_utc: String, relative_path: Option, bytes_copied: u64, } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawWorkflowSubject { role: SccmRole, instance_handle: Option, + #[serde(default)] + basis: Option, } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawConfiguredPathProvenance { state: String, path_class: Option, @@ -1366,7 +1509,7 @@ struct RawConfiguredPathProvenance { } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawServerRotation { kind: String, value: Option, @@ -1374,7 +1517,7 @@ struct RawServerRotation { } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] struct RawCollectionLimit { byte_limit: u64, limit_applied: bool, 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 index c1653dc3f..6043945d3 100644 --- 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 @@ -6,15 +6,12 @@ "sameBasename": "MP_GetPolicy.log", "distinctPathFingerprints": true, "distinctOpaqueRootSegments": true, - "destinationsPrecomputed": true, - "atomicCreateNoOverwrite": true, - "neitherOverwritten": true, "notMerged": true, "normalizedArtifactCount": 2, "exactReferencesResolve": true }, "artifactProvenance": [ - { "artifactId": "mp-policy-root-a-current", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false, "bytesCopied": 173 }, - { "artifactId": "mp-policy-root-b-current", "encoding": "utf-8", "byteLimit": 4096, "limitApplied": false, "bytesCopied": 172 } + { "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/unsorted-manifest/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/unsorted-manifest/expected.json index fa8f3f9f5..0efb1836c 100644 --- 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 @@ -5,7 +5,7 @@ "artifactIdDerivationIgnoresDiscoveryOrder": true, "artifactIdUniquenessScope": "manifest", "crossBundleArtifactIdReuseAllowed": true, - "deterministicEvidenceIds": "pending #318 deterministic evidence contract", + "deterministicEvidenceIds": true, "coverage": [ { "producerRole": "managementPoint", "sourceId": "server-mp-policy", "state": "captured" }, { "producerRole": "siteServer", "sourceId": "server-sitecomp", "state": "captured" }, diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index 0716045e3..043f0ff34 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -7,6 +7,7 @@ use cmtraceopen_parser::sccm::{ SccmFindingCoverageGap, SccmPhase, SccmRole, SccmRotation, }; use serde_json::{json, Value}; +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; fn intake_root() -> PathBuf { @@ -149,6 +150,313 @@ fn artifact_json<'a>(assessment: &'a Value, artifact_id: &str) -> &'a Value { .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" => assert!(artifacts + .iter() + .all(|artifact| { artifact["originalBasename"].as_str() == value.as_str() })), + "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('/') + .nth(5) + .filter(|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 repeated = assess_server_intake(manifest_json, payloads) + .expect("a separate assessment may reuse manifest-scoped IDs"); + assert_eq!( + serde_json::to_value(repeated).expect("repeat serializes"), + *actual + ); + } + "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, @@ -314,6 +622,7 @@ 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!( @@ -713,6 +1022,7 @@ fn server_intake_suppresses_absent_default_request_when_configured_source_is_usa 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() @@ -744,6 +1054,7 @@ fn server_intake_does_not_suppress_default_request_across_producer_hosts() { 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() @@ -994,20 +1305,171 @@ fn server_intake_retains_unsupported_source_provenance() { } #[test] -fn server_intake_tolerates_future_safe_unsupported_source_labels() { +fn server_intake_rejects_unversioned_future_unsupported_source_labels() { let (manifest_json, payloads) = load_bundle("unsupported-db-supplement"); let mut manifest = manifest_value(&manifest_json); manifest["artifacts"][0]["sourceId"] = Value::String("future-server-supplement".to_owned()); manifest["artifacts"][0]["sourceKind"] = Value::String("futureSupplement".to_owned()); - let assessment = assess_server_intake(&serialize_manifest(&manifest), &payloads) - .expect("a privacy-safe future unsupported label remains forward-compatible"); - let serialized = serde_json::to_value(&assessment).expect("assessment serializes"); - let artifact = artifact_json(&serialized, "unknown-db-export"); + assert!( + assess_server_intake(&serialize_manifest(&manifest), &payloads).is_err(), + "future provenance needs a versioned opaque handle, not an arbitrary public label" + ); +} + +#[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()); + }); + } +} - assert_eq!(artifact["sourceId"], "future-server-supplement"); - assert_eq!(artifact["sourceKind"], "futureSupplement"); - assert_eq!(artifact["family"], "future-server-supplement"); +#[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_rejects_unknown_fields_instead_of_dropping_them() { + let (manifest_json, payloads) = load_bundle("multiline"); + + for path in ["manifest", "topology", "artifact"] { + let mut manifest = manifest_value(&manifest_json); + match path { + "manifest" => manifest["unexpectedEvidence"] = Value::Bool(true), + "topology" => manifest["topology"]["unexpectedEvidence"] = Value::Bool(true), + "artifact" => manifest["artifacts"][0]["unexpectedEvidence"] = Value::Bool(true), + _ => unreachable!(), + } + assert!( + assess_server_intake(&serialize_manifest(&manifest), &payloads).is_err(), + "unknown {path} fields require a schema change; they cannot be silently discarded" + ); + } +} + +#[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 = 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()) + .flat_map(|entry| { + let scenario = entry.file_name().to_string_lossy().into_owned(); + 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 entry in std::fs::read_dir(intake_root()).expect("intake fixture root is readable") { + let entry = entry.expect("fixture directory entry is readable"); + let expected_path = entry.path().join("expected.json"); + if !expected_path.is_file() { + continue; + } + let scenario = entry.file_name().to_string_lossy().into_owned(); + 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] @@ -1129,12 +1591,53 @@ fn server_intake_exercises_committed_expected_contracts() { "{scenario}: coverage row count" ); for (actual_row, expected_row) in actual_coverage.iter().zip(expected_coverage) { - for key in ["producerRole", "workflowSubjectRole", "sourceId", "state"] { - if let Some(expected_value) = expected_row.get(key) { - assert_eq!( - &actual_row[key], expected_value, - "{scenario}: coverage {key}" - ); + 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}"), } } } @@ -1148,23 +1651,22 @@ fn server_intake_exercises_committed_expected_contracts() { .as_str() .expect("expected artifactId is a string"); let actual_artifact = artifact_json(&actual, artifact_id); - for (expected_key, actual_value) in [ - ( - "encoding", - &actual_artifact["captureProvenance"]["encoding"], - ), - ( - "byteLimit", - &actual_artifact["captureProvenance"]["byteLimit"], - ), - ( - "limitApplied", - &actual_artifact["captureProvenance"]["limitApplied"], - ), - ("bytesCopied", &actual_artifact["bytesCopied"]), - ("relativePath", &actual_artifact["relativePath"]), - ] { - if let Some(expected_value) = expected_artifact.get(expected_key) { + 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}" @@ -1199,6 +1701,17 @@ fn server_intake_exercises_committed_expected_contracts() { assert_eq!(records[0]["reference"]["lineStart"], line_range["start"]); assert_eq!(records[0]["reference"]["lineEnd"], line_range["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" + ); } } @@ -1224,5 +1737,13 @@ fn server_intake_exercises_committed_expected_contracts() { 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, + ); } } From d07f4613b904925b66ee9656634d380cdba584f3 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 00:20:53 -0400 Subject: [PATCH 261/422] fix(sccm): close server intake review gaps --- .../src/sccm/server/windows/intake.rs | 13 +++++- .../tests/sccm_server_intake.rs | 40 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 119c9faba..da1552c2c 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -6,6 +6,7 @@ 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, @@ -368,10 +369,17 @@ fn validate_manifest_bounds( } 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 { return Err(SccmServerIntakeError::ManifestLimitExceeded); @@ -1010,6 +1018,7 @@ fn parse_declared_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()) } @@ -1024,7 +1033,9 @@ fn parse_retained_rotation( if rotation.kind == "none" && rotation.value.is_none() { return Ok(None); } - parse_declared_rotation(rotation) + parse_declared_rotation(rotation)? + .ok_or(SccmServerIntakeError::InvalidArtifact) + .map(Some) } fn parse_configured_path_state( diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index 043f0ff34..63cc7998e 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -1472,6 +1472,27 @@ fn server_intake_expected_oracles_do_not_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); @@ -1502,6 +1523,25 @@ fn server_intake_bounds_aggregate_declared_bytes() { ); } +#[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"); From 813cfb2a4c3be685f8db70b2ad90249f2a972afc Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 00:29:36 -0400 Subject: [PATCH 262/422] test(sccm): close server intake oracle gaps --- .../tests/sccm_server_intake.rs | 184 ++++++++++++------ 1 file changed, 129 insertions(+), 55 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index 63cc7998e..bc685e31a 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -52,6 +52,21 @@ fn load_expected(scenario: &str) -> Value { 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}") } @@ -186,9 +201,21 @@ fn assert_collision_contract(scenario: &str, expected: &Value, actual: &Value) { .expect("collision assertions are an object") { match key.as_str() { - "sameBasename" => assert!(artifacts - .iter() - .all(|artifact| { artifact["originalBasename"].as_str() == value.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() @@ -411,12 +438,31 @@ fn assert_remaining_expected_contracts( } "crossBundleArtifactIdReuseAllowed" => { assert_eq!(value, true); - let repeated = assess_server_intake(manifest_json, payloads) - .expect("a separate assessment may reuse manifest-scoped IDs"); + let mut second_manifest = manifest_value(manifest_json); + second_manifest["topology"]["captureHost"] = + if second_manifest["syntheticFixture"] == true { + Value::String("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!( - serde_json::to_value(repeated).expect("repeat serializes"), - *actual + 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); @@ -1424,12 +1470,9 @@ fn server_intake_expected_oracle_has_no_unhandled_contract_keys() { .into_iter() .map(str::to_owned) .collect::>(); - let all_expected = 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()) - .flat_map(|entry| { - let scenario = entry.file_name().to_string_lossy().into_owned(); + let all_expected = intake_scenarios() + .into_iter() + .flat_map(|scenario| { load_expected(&scenario) .as_object() .expect("expected contract is an object") @@ -1453,13 +1496,7 @@ fn server_intake_expected_oracles_do_not_claim_native_collection_acceptance() { "neitherOverwritten", ]); - for entry in std::fs::read_dir(intake_root()).expect("intake fixture root is readable") { - let entry = entry.expect("fixture directory entry is readable"); - let expected_path = entry.path().join("expected.json"); - if !expected_path.is_file() { - continue; - } - let scenario = entry.file_name().to_string_lossy().into_owned(); + for scenario in intake_scenarios() { let expected = load_expected(&scenario); let asserted_collision_keys = expected["collisionAssertions"] .as_object() @@ -1497,18 +1534,30 @@ fn server_intake_rejects_ambiguous_retained_unknown_rotations() { fn server_intake_bounds_each_declared_artifact_limit() { let (manifest_json, payloads) = bounded_manifest(1, 268_435_457); - assert!( - assess_server_intake(&manifest_json, &payloads).is_err(), + 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!( - assess_server_intake(&manifest_json, &payloads).is_err(), + assert_eq!( + assess_server_intake(&manifest_json, &payloads), + Err(SccmServerIntakeError::ManifestLimitExceeded), "a manifest may not force unbounded per-artifact work" ); } @@ -1517,8 +1566,9 @@ fn server_intake_bounds_manifest_artifact_count() { fn server_intake_bounds_aggregate_declared_bytes() { let (manifest_json, payloads) = bounded_manifest(5, 268_435_456); - assert!( - assess_server_intake(&manifest_json, &payloads).is_err(), + assert_eq!( + assess_server_intake(&manifest_json, &payloads), + Err(SccmServerIntakeError::ManifestLimitExceeded), "aggregate declared collection work may not exceed 1 GiB" ); } @@ -1581,15 +1631,7 @@ fn server_intake_requires_producer_host_for_declared_sources() { #[test] fn server_intake_exercises_committed_expected_contracts() { - 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(); - - for scenario in scenarios { + 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) @@ -1601,22 +1643,46 @@ fn server_intake_exercises_committed_expected_contracts() { "{scenario}: expected contract version" ); - for key in [ - "canonicalArtifactIds", - "canonicalRotationArtifactIds", - "retainedUnclassifiedArtifactIds", - ] { - if let Some(expected_ids) = expected.get(key) { - let actual_ids = Value::Array( - actual["artifacts"] - .as_array() - .expect("assessment artifacts are an array") - .iter() - .map(|artifact| artifact["artifactId"].clone()) - .collect(), - ); - assert_eq!(actual_ids, *expected_ids, "{scenario}: {key}"); - } + 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"] @@ -1737,10 +1803,18 @@ fn server_intake_exercises_committed_expected_contracts() { .expect("logicalRecordCount is an integer"), "{scenario}: logical record count" ); - if let Some(line_range) = expected_row.get("lineRange") { - assert_eq!(records[0]["reference"]["lineStart"], line_range["start"]); - assert_eq!(records[0]["reference"]["lineEnd"], line_range["end"]); - } + 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") From 0f892a8774d9303574274b0d475212a033603f52 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 00:45:27 -0400 Subject: [PATCH 263/422] fix(sccm): retain safe forward intake provenance --- .../src/sccm/server/windows/intake.rs | 103 ++++++++++- .../tests/sccm_server_intake.rs | 166 ++++++++++++++++-- 2 files changed, 249 insertions(+), 20 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index da1552c2c..1263f27d9 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -20,6 +20,7 @@ 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; type PathFingerprintKey = ( String, @@ -57,6 +58,22 @@ pub struct SccmServerIntakeAssessment { pub evidence: Vec, pub findings: Vec, pub next_artifact_requests: Vec, + /// Versioned opaque manifest extensions retained without interpreting them. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub 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, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmServerOpaqueExtension { + pub schema_version: u32, + pub name: String, + pub value: String, } #[derive(Debug, Clone, PartialEq, Serialize)] @@ -65,6 +82,8 @@ pub struct SccmServerTopologyAssessment { pub capture_host_handle: String, pub site_handle: String, pub roles_observed: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub extensions: Vec, } #[derive(Debug, Clone, PartialEq, Serialize)] @@ -95,6 +114,8 @@ pub struct SccmServerArtifactAssessment { pub fragment_complete: Option, pub capture_provenance: Option, pub parser_eligible: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub extensions: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -183,6 +204,15 @@ pub fn assess_server_intake( 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 + && artifact.capture_state == SccmCoverageState::Unsupported + }) + }) { + 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) @@ -321,6 +351,7 @@ pub fn assess_server_intake( evidence, findings: Vec::new(), next_artifact_requests, + extensions: normalize_opaque_extensions(&manifest.extensions)?, }) } @@ -476,7 +507,7 @@ fn normalize_topology( .topology .roles_observed .iter() - .any(|role| !is_declared_server_role(role)) + .any(|role| !is_declared_server_role(role) && !is_opaque_future_role(role)) { return Err(SccmServerIntakeError::InvalidTopology); } @@ -490,6 +521,7 @@ fn normalize_topology( capture_host_handle, site_handle, roles_observed, + extensions: normalize_opaque_extensions(&manifest.topology.extensions)?, }) } @@ -502,8 +534,11 @@ fn normalize_artifact( canonical_artifact_identities: &mut BTreeSet, payload_by_id: &BTreeMap<&str, &[u8]>, ) -> Result { + let synthetic_unclassified = + artifact.producer_role == SccmRole::Unknown("unclassified".to_owned()); + let opaque_future_role = is_opaque_future_role(&artifact.producer_role); let unsupported_unknown = artifact.capture_state == SccmCoverageState::Unsupported - && artifact.producer_role == SccmRole::Unknown("unclassified".to_owned()); + && (synthetic_unclassified || opaque_future_role); validate_artifact_annotations(&artifact, synthetic_fixture)?; let source_version = normalize_source_version(artifact.source_version.as_deref(), synthetic_fixture)?; @@ -541,8 +576,8 @@ fn normalize_artifact( } let producer_is_observed = roles_observed.contains(&artifact.producer_role); - if (!producer_is_observed && !unsupported_unknown) - || (producer_is_observed && !is_declared_server_role(&artifact.producer_role)) + if (!producer_is_observed && !synthetic_unclassified) + || (!is_declared_server_role(&artifact.producer_role) && !unsupported_unknown) { return Err(SccmServerIntakeError::InvalidArtifact); } @@ -762,6 +797,7 @@ fn normalize_artifact( fragment_complete: artifact.fragment_complete, capture_provenance, parser_eligible, + extensions: normalize_opaque_extensions(&artifact.extensions)?, }, evidence, }) @@ -1351,7 +1387,47 @@ fn safe_original_path_marker(value: &str, synthetic_fixture: bool) -> bool { .bytes() .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'); } - !value.is_empty() && value.len() <= 1024 && !value.chars().any(char::is_control) + value == "REDACTED" || opaque_sha256_handle(value, "cmtraceopen.original-path.sha256.v1:") +} + +fn normalize_opaque_extensions( + extensions: &BTreeMap, +) -> Result, SccmServerIntakeError> { + if extensions.len() > MAX_SCCM_SERVER_OPAQUE_EXTENSIONS { + return Err(SccmServerIntakeError::ManifestLimitExceeded); + } + + extensions + .iter() + .map(|(name, value)| { + let value = value + .as_str() + .ok_or(SccmServerIntakeError::InvalidArtifact)?; + if !safe_opaque_extension_name(name) + || !opaque_sha256_handle(value, "cmtraceopen.extension.sha256.v1:") + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + Ok(SccmServerOpaqueExtension { + schema_version: 1, + name: name.clone(), + value: value.to_owned(), + }) + }) + .collect() +} + +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 { @@ -1397,6 +1473,11 @@ fn is_declared_server_role(role: &SccmRole) -> bool { ) } +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", @@ -1437,7 +1518,7 @@ fn rotation_sort_key(rotation: Option<&SccmRotation>) -> String { } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] struct RawServerManifest { sccm_manifest_version: u32, #[serde(default)] @@ -1451,6 +1532,8 @@ struct RawServerManifest { #[serde(default)] input_order_is_deliberately_unsorted: Option, artifacts: Vec, + #[serde(flatten)] + extensions: BTreeMap, } #[derive(Debug, Deserialize)] @@ -1461,15 +1544,17 @@ struct RawServerPrivacy { } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] struct RawServerTopology { capture_host: String, site_code: String, roles_observed: Vec, + #[serde(flatten)] + extensions: BTreeMap, } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] struct RawServerArtifact { artifact_id: String, producer_role: SccmRole, @@ -1500,6 +1585,8 @@ struct RawServerArtifact { collected_utc: String, relative_path: Option, bytes_copied: u64, + #[serde(flatten)] + extensions: BTreeMap, } #[derive(Debug, Deserialize)] diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index bc685e31a..6b09959f4 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -1408,24 +1408,166 @@ fn server_intake_accepts_only_opaque_future_unsupported_provenance() { } #[test] -fn server_manifest_v1_rejects_unknown_fields_instead_of_dropping_them() { - let (manifest_json, payloads) = load_bundle("multiline"); +fn server_manifest_v1_retains_only_versioned_opaque_extensions_deterministically() { + let (manifest_json, payloads) = bounded_manifest(1, 4_096); + let mut manifest = manifest_value(&manifest_json); + 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); + + manifest[extension_name_b] = Value::String(extension_value_b.clone()); + manifest[extension_name_a] = Value::String(extension_value_a.clone()); + manifest["topology"][extension_name_b] = Value::String(extension_value_b.clone()); + manifest["topology"][extension_name_a] = Value::String(extension_value_a.clone()); + manifest["artifacts"][0][extension_name_b] = Value::String(extension_value_b.clone()); + manifest["artifacts"][0][extension_name_a] = Value::String(extension_value_a.clone()); + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &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 mut reordered = manifest_value(&manifest_json); + reordered[extension_name_a] = Value::String(extension_value_a.clone()); + reordered[extension_name_b] = Value::String(extension_value_b.clone()); + reordered["topology"][extension_name_a] = Value::String(extension_value_a.clone()); + reordered["topology"][extension_name_b] = Value::String(extension_value_b.clone()); + reordered["artifacts"][0][extension_name_a] = Value::String(extension_value_a.clone()); + reordered["artifacts"][0][extension_name_b] = Value::String(extension_value_b.clone()); + let reordered_assessment = assess_server_intake(&serialize_manifest(&reordered), &payloads) + .expect("extension arrival order does not change normalized output"); + assert_eq!(assessment, reordered_assessment); +} + +#[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"] { - let mut manifest = manifest_value(&manifest_json); - match path { - "manifest" => manifest["unexpectedEvidence"] = Value::Bool(true), - "topology" => manifest["topology"]["unexpectedEvidence"] = Value::Bool(true), - "artifact" => manifest["artifacts"][0]["unexpectedEvidence"] = Value::Bool(true), - _ => unreachable!(), + 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!(), + } + assert!( + assess_server_intake(&serialize_manifest(&manifest), &payloads).is_err(), + "{path} must reject arbitrary extension {name}" + ); } - assert!( - assess_server_intake(&serialize_manifest(&manifest), &payloads).is_err(), - "unknown {path} fields require a schema change; they cannot be silently discarded" - ); } } +#[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_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_roles_without_unsupported_capture() { + 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!( + assess_server_intake(&serialize_manifest(&manifest), &payloads).is_err(), + "future roles are valid only when retained by an unsupported capture" + ); +} + +#[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 = [ From 82cc77c8cb1cdeaaf114e476177a89f8e5011514 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 01:22:57 -0400 Subject: [PATCH 264/422] fix(sccm): close opaque intake boundaries --- .../src/sccm/server/windows/intake.rs | 859 +++++++++++++++++- .../tests/sccm_server_intake.rs | 341 ++++++- 2 files changed, 1149 insertions(+), 51 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 1263f27d9..3ec3635e2 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -1,7 +1,10 @@ use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; use chrono::{DateTime, SecondsFormat, Utc}; -use serde::{Deserialize, Serialize}; +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; @@ -21,6 +24,9 @@ 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; type PathFingerprintKey = ( String, @@ -48,8 +54,7 @@ pub struct SccmServerArtifactPayload { pub bytes: Vec, } -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, PartialEq)] pub struct SccmServerIntakeAssessment { pub schema_version: u32, pub topology: SccmServerTopologyAssessment, @@ -59,8 +64,8 @@ pub struct SccmServerIntakeAssessment { pub findings: Vec, pub next_artifact_requests: Vec, /// Versioned opaque manifest extensions retained without interpreting them. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub extensions: Vec, + extensions: Vec, + privacy_extensions: Vec, } /// A public, deterministic representation of a recognized opaque manifest extension. @@ -68,12 +73,150 @@ pub struct SccmServerIntakeAssessment { /// 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, Serialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SccmServerOpaqueExtension { - pub schema_version: u32, - pub name: String, - pub value: String, + schema_version: u32, + name: String, + value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum SccmServerOpaqueExtensionError { + #[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 || !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 + } +} + +#[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)] @@ -82,8 +225,17 @@ pub struct SccmServerTopologyAssessment { pub capture_host_handle: String, pub site_handle: String, pub roles_observed: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub extensions: Vec, + #[serde( + skip_serializing_if = "Vec::is_empty", + serialize_with = "serialize_opaque_extensions" + )] + extensions: Vec, +} + +impl SccmServerTopologyAssessment { + pub fn extensions(&self) -> &[SccmServerOpaqueExtension] { + &self.extensions + } } #[derive(Debug, Clone, PartialEq, Serialize)] @@ -114,8 +266,53 @@ pub struct SccmServerArtifactAssessment { pub fragment_complete: Option, pub capture_provenance: Option, pub parser_eligible: bool, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub extensions: Vec, + #[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)] @@ -192,6 +389,7 @@ pub fn assess_server_intake( 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 { @@ -351,7 +549,21 @@ pub fn assess_server_intake( evidence, findings: Vec::new(), next_artifact_requests, - extensions: normalize_opaque_extensions(&manifest.extensions)?, + 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(), }) } @@ -503,11 +715,10 @@ fn normalize_topology( }; if manifest.topology.roles_observed.is_empty() - || manifest - .topology - .roles_observed - .iter() - .any(|role| !is_declared_server_role(role) && !is_opaque_future_role(role)) + || manifest.topology.roles_observed.iter().any(|role| { + !is_declared_server_role(role) + && (manifest.synthetic_fixture || !is_opaque_future_role(role)) + }) { return Err(SccmServerIntakeError::InvalidTopology); } @@ -521,7 +732,10 @@ fn normalize_topology( capture_host_handle, site_handle, roles_observed, - extensions: normalize_opaque_extensions(&manifest.topology.extensions)?, + extensions: normalize_opaque_extensions( + &manifest.topology.extensions, + SccmServerIntakeError::InvalidTopology, + )?, }) } @@ -534,9 +748,35 @@ fn normalize_artifact( 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 synthetic_unclassified = artifact.producer_role == SccmRole::Unknown("unclassified".to_owned()); - let opaque_future_role = is_opaque_future_role(&artifact.producer_role); + let opaque_future_role = !synthetic_fixture && is_opaque_future_role(&artifact.producer_role); let unsupported_unknown = artifact.capture_state == SccmCoverageState::Unsupported && (synthetic_unclassified || opaque_future_role); validate_artifact_annotations(&artifact, synthetic_fixture)?; @@ -797,7 +1037,11 @@ fn normalize_artifact( fragment_complete: artifact.fragment_complete, capture_provenance, parser_eligible, - extensions: normalize_opaque_extensions(&artifact.extensions)?, + extensions, + workflow_subject_extensions, + configured_path_provenance_extensions, + rotation_extensions, + collection_limit_extensions, }, evidence, }) @@ -1392,29 +1636,90 @@ fn safe_original_path_marker(value: &str, synthetic_fixture: bool) -> bool { fn normalize_opaque_extensions( extensions: &BTreeMap, + scope_error: SccmServerIntakeError, ) -> Result, SccmServerIntakeError> { - if extensions.len() > MAX_SCCM_SERVER_OPAQUE_EXTENSIONS { - return Err(SccmServerIntakeError::ManifestLimitExceeded); - } - - extensions + let normalized = extensions .iter() .map(|(name, value)| { - let value = value - .as_str() - .ok_or(SccmServerIntakeError::InvalidArtifact)?; - if !safe_opaque_extension_name(name) - || !opaque_sha256_handle(value, "cmtraceopen.extension.sha256.v1:") - { - return Err(SccmServerIntakeError::InvalidArtifact); - } - Ok(SccmServerOpaqueExtension { - schema_version: 1, - name: name.clone(), - value: value.to_owned(), - }) + 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() + .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 { @@ -1517,6 +1822,286 @@ fn rotation_sort_key(rotation: Option<&SccmRotation>) -> String { } } +#[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, + &[ + "sccmManifestVersion", + "syntheticFixture", + "proposalOnly", + "privacy", + "bundleRole", + "topology", + "inputOrderIsDeliberatelyUnsorted", + "artifacts", + ], + 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, + &["synthetic", "rawPaths"], + 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, + &["captureHost", "siteCode", "rolesObserved"], + SccmServerIntakeError::InvalidTopology, + &mut totals, + )?; + } + if let Some(PreservedJsonValue::Array(artifacts)) = preserved_field(manifest, "artifacts") { + for artifact in artifacts { + let PreservedJsonValue::Object(artifact) = artifact else { + continue; + }; + validate_unique_object_fields(artifact, SccmServerIntakeError::InvalidArtifact)?; + validate_preserved_extensions( + artifact, + &[ + "artifactId", + "producerRole", + "producerHostHandle", + "workflowSubject", + "sourceId", + "sourceKind", + "sourceVersion", + "originalPath", + "originalBasename", + "configuredPathProvenance", + "defaultCandidateState", + "rotation", + "captureState", + "collectionDetail", + "skipReason", + "unsupportedReason", + "encoding", + "collectionLimit", + "truncated", + "fragmentComplete", + "collectedUtc", + "relativePath", + "bytesCopied", + ], + SccmServerIntakeError::InvalidArtifact, + &mut totals, + )?; + for (field, known) in [ + ("workflowSubject", &["role", "instanceHandle", "basis"][..]), + ( + "configuredPathProvenance", + &["state", "pathClass", "pathFingerprint"][..], + ), + ("rotation", &["kind", "value", "lineageId"][..]), + ("collectionLimit", &["byteLimit", "limitApplied"][..]), + ] { + 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(()) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct RawServerManifest { @@ -1537,10 +2122,12 @@ struct RawServerManifest { } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] struct RawServerPrivacy { synthetic: bool, raw_paths: String, + #[serde(flatten)] + extensions: BTreeMap, } #[derive(Debug, Deserialize)] @@ -1590,33 +2177,211 @@ struct RawServerArtifact { } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] struct RawWorkflowSubject { role: SccmRole, instance_handle: Option, #[serde(default)] basis: Option, + #[serde(flatten)] + extensions: BTreeMap, } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] struct RawConfiguredPathProvenance { state: String, path_class: Option, path_fingerprint: String, + #[serde(flatten)] + extensions: BTreeMap, } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] struct RawServerRotation { kind: String, value: Option, lineage_id: String, + #[serde(flatten)] + extensions: BTreeMap, } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] struct RawCollectionLimit { byte_limit: u64, limit_applied: bool, + #[serde(flatten)] + extensions: BTreeMap, +} + +#[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}"), + } + } + + 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() { + 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, + 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/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index 6b09959f4..c3c5716ff 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -46,6 +46,56 @@ fn serialize_manifest(manifest: &Value) -> String { serde_json::to_string(manifest).expect("manifest serializes") } +fn manifest_with_duplicate_extension( + manifest_json: &str, + scope: &str, + extension_name: &str, + first_value: &str, + second_value: &str, +) -> String { + let needle = match scope { + "manifest" => "{", + "privacy" => "\"privacy\":{", + "topology" => "\"topology\":{", + "artifact" => "\"artifacts\":[{", + "workflowSubject" => "\"workflowSubject\":{", + "configuredPathProvenance" => "\"configuredPathProvenance\":{", + "rotation" => "\"rotation\":{", + "collectionLimit" => "\"collectionLimit\":{", + _ => panic!("unknown extension scope: {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 = match scope { + "manifest" => "{", + "privacy" => "\"privacy\":{", + "topology" => "\"topology\":{", + "artifact" => "\"artifacts\":[{", + "workflowSubject" => "\"workflowSubject\":{", + "configuredPathProvenance" => "\"configuredPathProvenance\":{", + "rotation" => "\"rotation\":{", + "collectionLimit" => "\"collectionLimit\":{", + _ => panic!("unknown extension scope: {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 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"); @@ -1401,7 +1451,7 @@ fn server_intake_accepts_only_opaque_future_unsupported_provenance() { 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"); + 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); @@ -1446,6 +1496,20 @@ fn server_manifest_v1_retains_only_versioned_opaque_extensions_deterministically 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_v1_rejects_unversioned_or_nonopaque_unknown_fields() { let (manifest_json, payloads) = bounded_manifest(1, 4_096); @@ -1465,14 +1529,268 @@ fn server_manifest_v1_rejects_unversioned_or_nonopaque_unknown_fields() { "artifact" => manifest["artifacts"][0][name] = value, _ => unreachable!(), } - assert!( - assess_server_intake(&serialize_manifest(&manifest), &payloads).is_err(), - "{path} must reject arbitrary extension {name}" + 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); @@ -1548,6 +1866,21 @@ fn server_intake_rejects_future_roles_without_unsupported_capture() { ); } +#[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); From ceb487e60d9b9d1de635c9eef5d83a054ffa3e2d Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 01:51:51 -0400 Subject: [PATCH 265/422] fix(sccm): decouple future role coverage --- .../src/sccm/server/windows/intake.rs | 91 ++++--- .../tests/sccm_server_intake.rs | 257 +++++++++++++++++- 2 files changed, 308 insertions(+), 40 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 3ec3635e2..f317cdfb8 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -404,10 +404,10 @@ pub fn assess_server_intake( 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 - && artifact.capture_state == SccmCoverageState::Unsupported - }) + && !manifest + .artifacts + .iter() + .any(|artifact| artifact.producer_role == *role) }) { return Err(SccmServerIntakeError::InvalidTopology); } @@ -657,12 +657,12 @@ fn validate_manifest_metadata(manifest: &RawServerManifest) -> Result<(), SccmSe let privacy = manifest .privacy .as_ref() - .ok_or(SccmServerIntakeError::InvalidArtifact)?; + .ok_or(SccmServerIntakeError::MalformedManifest)?; if manifest.proposal_only != Some(true) || !privacy.synthetic || privacy.raw_paths != "redacted" { - return Err(SccmServerIntakeError::InvalidArtifact); + return Err(SccmServerIntakeError::MalformedManifest); } } else if manifest.proposal_only == Some(true) || manifest @@ -670,11 +670,11 @@ fn validate_manifest_metadata(manifest: &RawServerManifest) -> Result<(), SccmSe .as_ref() .is_some_and(|privacy| privacy.synthetic || privacy.raw_paths != "redacted") { - return Err(SccmServerIntakeError::InvalidArtifact); + return Err(SccmServerIntakeError::MalformedManifest); } if manifest.input_order_is_deliberately_unsorted == Some(false) { - return Err(SccmServerIntakeError::InvalidArtifact); + return Err(SccmServerIntakeError::MalformedManifest); } Ok(()) } @@ -777,18 +777,14 @@ fn normalize_artifact( let synthetic_unclassified = artifact.producer_role == SccmRole::Unknown("unclassified".to_owned()); let opaque_future_role = !synthetic_fixture && is_opaque_future_role(&artifact.producer_role); - let unsupported_unknown = artifact.capture_state == SccmCoverageState::Unsupported - && (synthetic_unclassified || opaque_future_role); + let retained_unknown = opaque_future_role + || (synthetic_unclassified && 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, unsupported_unknown, synthetic_fixture) - || !safe_source_kind( - &artifact.source_kind, - unsupported_unknown, - 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, @@ -808,8 +804,9 @@ fn normalize_artifact( synthetic_fixture, "subject", ) - || (!unsupported_unknown && artifact.producer_host_handle.is_none()) - || (unsupported_unknown + || ((!retained_unknown || is_physical_state(&artifact.capture_state)) + && artifact.producer_host_handle.is_none()) + || (retained_unknown && !safe_public_basename(&artifact.original_basename, synthetic_fixture)) { return Err(SccmServerIntakeError::InvalidArtifact); @@ -817,7 +814,7 @@ fn normalize_artifact( let producer_is_observed = roles_observed.contains(&artifact.producer_role); if (!producer_is_observed && !synthetic_unclassified) - || (!is_declared_server_role(&artifact.producer_role) && !unsupported_unknown) + || (!is_declared_server_role(&artifact.producer_role) && !retained_unknown) { return Err(SccmServerIntakeError::InvalidArtifact); } @@ -862,7 +859,7 @@ fn normalize_artifact( } else { (family, None, None, false) } - } else if unsupported_unknown { + } else if retained_unknown { ( SccmArtifactFamily::Unknown(artifact.source_id.clone()), Some(artifact.original_basename.clone()), @@ -1172,16 +1169,18 @@ fn validate_relative_path( 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, - artifact.source_id.as_str(), + expected_role.as_str(), + expected_source.as_str(), ]; if components.get(..fixed_prefix.len()) != Some(fixed_prefix.as_slice()) { return Err(SccmServerIntakeError::InvalidArtifact); @@ -1216,7 +1215,7 @@ fn validate_relative_path( } if components.get(cursor).copied() != Some(expected_rotation.as_str()) - || components.get(cursor + 1).copied() != Some(basename) + || components.get(cursor + 1).copied() != Some(expected_basename.as_str()) || components.len() != cursor + 2 || relative_path.contains('\\') || relative_path.split('/').any(|segment| { @@ -1245,17 +1244,33 @@ fn safe_encoding(encoding: &str) -> bool { matches!(encoding, "utf-8" | "utf-16le" | "windows-1252" | "unknown") } -fn role_path_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 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 { @@ -1756,8 +1771,8 @@ fn safe_optional_handle(value: Option<&str>, synthetic_fixture: bool, domain: &s opaque_sha256_handle(value, &format!("cmtraceopen.{domain}.sha256.v1:")) } -fn opaque_sha256_handle(value: &str, prefix: &str) -> bool { - value.strip_prefix(prefix).is_some_and(|digest| { +fn opaque_sha256_digest<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { + value.strip_prefix(prefix).filter(|digest| { digest.len() == 64 && digest .bytes() @@ -1765,6 +1780,10 @@ fn opaque_sha256_handle(value: &str, prefix: &str) -> bool { }) } +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, diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index c3c5716ff..ac2c4fb4c 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -182,6 +182,106 @@ fn bounded_manifest( ) } +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, @@ -1510,6 +1610,55 @@ fn server_manifest_version_gate_precedes_v1_extension_validation() { ); } +#[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); @@ -1831,6 +1980,92 @@ fn server_intake_retains_only_opaque_future_roles_as_unsupported_coverage() { 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); @@ -1852,7 +2087,7 @@ fn server_intake_rejects_identity_bearing_future_roles() { } #[test] -fn server_intake_rejects_future_roles_without_unsupported_capture() { +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!([ @@ -1860,9 +2095,23 @@ fn server_intake_rejects_future_roles_without_unsupported_capture() { opaque_handle("cmtraceopen.role.sha256.v1:", 10), ]); - assert!( - assess_server_intake(&serialize_manifest(&manifest), &payloads).is_err(), - "future roles are valid only when retained by an unsupported capture" + 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" ); } From b433d0722f90d9549c54250b55518f2d15d70b82 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 02:16:25 -0400 Subject: [PATCH 266/422] test(sccm): require early server intake bounds --- .../cmtraceopen-parser/tests/sccm_server_intake.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index ac2c4fb4c..9bce481b7 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -2286,6 +2286,20 @@ fn server_intake_bounds_manifest_artifact_count() { ); } +#[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); From ad93b11c539cf51723b53dad95769f4a49183b21 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 02:16:31 -0400 Subject: [PATCH 267/422] fix(sccm): enforce server preflight bounds --- .../src/sccm/server/windows/intake.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index f317cdfb8..f53eecb0c 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -1045,8 +1045,15 @@ fn normalize_artifact( } fn payload_sha256(bytes: &[u8]) -> String { + const LOWER_HEX: &[u8; 16] = b"0123456789abcdef"; + let digest = Sha256::digest(bytes); - digest.iter().map(|byte| format!("{byte:02x}")).collect() + 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 validate_payload_contract<'a>( @@ -2008,6 +2015,9 @@ fn preflight_server_manifest_extensions(manifest_json: &str) -> Result<(), SccmS )?; } 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; From 07ee97c39a919abcca4a08bd86488e1dbd0a051b Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 02:34:35 -0400 Subject: [PATCH 268/422] fix(sccm): align server intake review contracts --- .../src/sccm/server/windows/intake.rs | 369 ++++++++++-------- .../tests/sccm_server_intake.rs | 133 +++++-- 2 files changed, 301 insertions(+), 201 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index f53eecb0c..139722e65 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -82,6 +82,8 @@ pub struct SccmServerOpaqueExtension { #[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")] @@ -115,7 +117,12 @@ impl SccmServerOpaqueExtension { } fn validate(&self) -> Result<(), SccmServerOpaqueExtensionError> { - if self.schema_version != 1 || !safe_opaque_extension_name(&self.name) { + 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:") { @@ -774,16 +781,16 @@ fn normalize_artifact( }) .transpose()? .unwrap_or_default(); - let synthetic_unclassified = + 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 - || (synthetic_unclassified && artifact.capture_state == SccmCoverageState::Unsupported); + || (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_id(&artifact.source_id, retained_unknown) || !safe_source_kind(&artifact.source_kind, retained_unknown, synthetic_fixture) || !safe_lineage_id(&artifact.rotation.lineage_id, synthetic_fixture) || !safe_path_fingerprint( @@ -813,7 +820,7 @@ fn normalize_artifact( } let producer_is_observed = roles_observed.contains(&artifact.producer_role); - if (!producer_is_observed && !synthetic_unclassified) + if (!producer_is_observed && !unclassified_producer) || (!is_declared_server_role(&artifact.producer_role) && !retained_unknown) { return Err(SccmServerIntakeError::InvalidArtifact); @@ -1146,14 +1153,7 @@ fn decode_server_payload( .map(Some) .map_err(|_| SccmServerIntakeError::InvalidPayloadEncoding) } - "windows-1252" => { - let (content, _, had_errors) = encoding_rs::WINDOWS_1252.decode(bytes); - if had_errors { - Err(SccmServerIntakeError::InvalidPayloadEncoding) - } else { - Ok(Some(content.into_owned())) - } - } + "windows-1252" => Ok(Some(encoding_rs::WINDOWS_1252.decode(bytes).0.into_owned())), "unknown" => Ok(None), _ => Err(SccmServerIntakeError::InvalidPayloadEncoding), } @@ -1561,7 +1561,7 @@ fn safe_manifest_artifact_id(value: &str, synthetic_fixture: bool) -> bool { opaque_sha256_handle(value, "cmtraceopen.artifact.sha256.v1:") } -fn safe_source_id(value: &str, allow_unknown: bool, synthetic_fixture: bool) -> bool { +fn safe_source_id(value: &str, allow_unknown: bool) -> bool { matches!( value, "server-sitecomp" @@ -1572,12 +1572,7 @@ fn safe_source_id(value: &str, allow_unknown: bool, synthetic_fixture: bool) -> | "server-dp-distribution" | "server-sup-sync" | "unknown-db-supplement" - ) || (allow_unknown - && if synthetic_fixture { - value == "unknown-db-supplement" - } else { - opaque_sha256_handle(value, "cmtraceopen.source.sha256.v1:") - }) + ) || (allow_unknown && opaque_sha256_handle(value, "cmtraceopen.source.sha256.v1:")) } fn safe_source_kind(value: &str, allow_unknown: bool, synthetic_fixture: bool) -> bool { @@ -1982,16 +1977,7 @@ fn preflight_server_manifest_extensions(manifest_json: &str) -> Result<(), SccmS let mut totals = OpaqueExtensionTotals::default(); validate_preserved_extensions( manifest, - &[ - "sccmManifestVersion", - "syntheticFixture", - "proposalOnly", - "privacy", - "bundleRole", - "topology", - "inputOrderIsDeliberatelyUnsorted", - "artifacts", - ], + RawServerManifest::KNOWN_FIELDS, SccmServerIntakeError::MalformedManifest, &mut totals, )?; @@ -2000,7 +1986,7 @@ fn preflight_server_manifest_extensions(manifest_json: &str) -> Result<(), SccmS validate_unique_object_fields(privacy, SccmServerIntakeError::MalformedManifest)?; validate_preserved_extensions( privacy, - &["synthetic", "rawPaths"], + RawServerPrivacy::KNOWN_FIELDS, SccmServerIntakeError::MalformedManifest, &mut totals, )?; @@ -2009,7 +1995,7 @@ fn preflight_server_manifest_extensions(manifest_json: &str) -> Result<(), SccmS validate_unique_object_fields(topology, SccmServerIntakeError::InvalidTopology)?; validate_preserved_extensions( topology, - &["captureHost", "siteCode", "rolesObserved"], + RawServerTopology::KNOWN_FIELDS, SccmServerIntakeError::InvalidTopology, &mut totals, )?; @@ -2025,42 +2011,18 @@ fn preflight_server_manifest_extensions(manifest_json: &str) -> Result<(), SccmS validate_unique_object_fields(artifact, SccmServerIntakeError::InvalidArtifact)?; validate_preserved_extensions( artifact, - &[ - "artifactId", - "producerRole", - "producerHostHandle", - "workflowSubject", - "sourceId", - "sourceKind", - "sourceVersion", - "originalPath", - "originalBasename", - "configuredPathProvenance", - "defaultCandidateState", - "rotation", - "captureState", - "collectionDetail", - "skipReason", - "unsupportedReason", - "encoding", - "collectionLimit", - "truncated", - "fragmentComplete", - "collectedUtc", - "relativePath", - "bytesCopied", - ], + RawServerArtifact::KNOWN_FIELDS, SccmServerIntakeError::InvalidArtifact, &mut totals, )?; for (field, known) in [ - ("workflowSubject", &["role", "instanceHandle", "basis"][..]), + ("workflowSubject", RawWorkflowSubject::KNOWN_FIELDS), ( "configuredPathProvenance", - &["state", "pathClass", "pathFingerprint"][..], + RawConfiguredPathProvenance::KNOWN_FIELDS, ), - ("rotation", &["kind", "value", "lineageId"][..]), - ("collectionLimit", &["byteLimit", "limitApplied"][..]), + ("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)?; @@ -2131,118 +2093,118 @@ fn validate_preserved_extensions( Ok(()) } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RawServerManifest { - sccm_manifest_version: u32, - #[serde(default)] - synthetic_fixture: bool, - #[serde(default)] - proposal_only: Option, - #[serde(default)] - privacy: Option, - bundle_role: String, - topology: RawServerTopology, - #[serde(default)] - input_order_is_deliberately_unsorted: Option, - artifacts: Vec, - #[serde(flatten)] - extensions: BTreeMap, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RawServerPrivacy { - synthetic: bool, - raw_paths: String, - #[serde(flatten)] - extensions: BTreeMap, +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),*]; + } + }; } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RawServerTopology { - capture_host: String, - site_code: String, - roles_observed: Vec, - #[serde(flatten)] - extensions: BTreeMap, +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, + } } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RawServerArtifact { - artifact_id: String, - producer_role: SccmRole, - producer_host_handle: Option, - workflow_subject: Option, - source_id: String, - source_kind: String, - source_version: Option, - original_path: String, - original_basename: String, - configured_path_provenance: RawConfiguredPathProvenance, - #[serde(default)] - default_candidate_state: Option, - rotation: RawServerRotation, - capture_state: SccmCoverageState, - #[serde(default)] - collection_detail: Option, - #[serde(default)] - skip_reason: Option, - #[serde(default)] - unsupported_reason: Option, - encoding: Option, - collection_limit: Option, - #[serde(default)] - truncated: Option, - #[serde(default)] - fragment_complete: Option, - collected_utc: String, - relative_path: Option, - bytes_copied: u64, - #[serde(flatten)] - extensions: BTreeMap, +define_raw_server_wire! { + struct RawServerPrivacy { + "synthetic" => synthetic: bool, + "rawPaths" => raw_paths: String, + } } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RawWorkflowSubject { - role: SccmRole, - instance_handle: Option, - #[serde(default)] - basis: Option, - #[serde(flatten)] - extensions: BTreeMap, +define_raw_server_wire! { + struct RawServerTopology { + "captureHost" => capture_host: String, + "siteCode" => site_code: String, + "rolesObserved" => roles_observed: Vec, + } } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RawConfiguredPathProvenance { - state: String, - path_class: Option, - path_fingerprint: String, - #[serde(flatten)] - extensions: BTreeMap, +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, + } } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RawServerRotation { - kind: String, - value: Option, - lineage_id: String, - #[serde(flatten)] - extensions: BTreeMap, +define_raw_server_wire! { + struct RawWorkflowSubject { + "role" => role: SccmRole, + "instanceHandle" => instance_handle: Option, + "basis" => basis: Option, + } } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RawCollectionLimit { - byte_limit: u64, - limit_applied: bool, - #[serde(flatten)] - extensions: BTreeMap, +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, + "limitApplied" => limit_applied: bool, + } } #[cfg(test)] @@ -2259,6 +2221,87 @@ mod opaque_extension_boundary_tests { } } + #[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"] + ); + 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", "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#"{ @@ -2355,6 +2398,10 @@ mod opaque_extension_boundary_tests { #[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| { diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index 9bce481b7..3eebf2ee6 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -46,14 +46,8 @@ fn serialize_manifest(manifest: &Value) -> String { serde_json::to_string(manifest).expect("manifest serializes") } -fn manifest_with_duplicate_extension( - manifest_json: &str, - scope: &str, - extension_name: &str, - first_value: &str, - second_value: &str, -) -> String { - let needle = match scope { +fn manifest_scope_needle(scope: &str) -> &'static str { + match scope { "manifest" => "{", "privacy" => "\"privacy\":{", "topology" => "\"topology\":{", @@ -63,7 +57,17 @@ fn manifest_with_duplicate_extension( "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}\"," ); @@ -78,17 +82,7 @@ fn manifest_with_duplicate_known_field( field_name: &str, field_value: &Value, ) -> String { - let needle = match scope { - "manifest" => "{", - "privacy" => "\"privacy\":{", - "topology" => "\"topology\":{", - "artifact" => "\"artifacts\":[{", - "workflowSubject" => "\"workflowSubject\":{", - "configuredPathProvenance" => "\"configuredPathProvenance\":{", - "rotation" => "\"rotation\":{", - "collectionLimit" => "\"collectionLimit\":{", - _ => panic!("unknown extension scope: {scope}"), - }; + 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); @@ -96,6 +90,32 @@ fn manifest_with_duplicate_known_field( 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"); @@ -383,8 +403,7 @@ fn assert_collision_contract(scenario: &str, expected: &Value, actual: &Value) { .as_str() .expect("relative path") .split('/') - .nth(5) - .filter(|segment| segment.starts_with("root-")) + .find(|segment| segment.starts_with("root-")) .expect("opaque configured-root segment") }) .collect::>(); @@ -591,7 +610,17 @@ fn assert_remaining_expected_contracts( let mut second_manifest = manifest_value(manifest_json); second_manifest["topology"]["captureHost"] = if second_manifest["syntheticFixture"] == true { - Value::String("LAB-MP01".to_owned()) + 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)) }; @@ -1560,20 +1589,51 @@ fn server_intake_accepts_only_opaque_future_unsupported_provenance() { #[test] fn server_manifest_v1_retains_only_versioned_opaque_extensions_deterministically() { let (manifest_json, payloads) = bounded_manifest(1, 4_096); - let mut manifest = manifest_value(&manifest_json); 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" + ); - manifest[extension_name_b] = Value::String(extension_value_b.clone()); - manifest[extension_name_a] = Value::String(extension_value_a.clone()); - manifest["topology"][extension_name_b] = Value::String(extension_value_b.clone()); - manifest["topology"][extension_name_a] = Value::String(extension_value_a.clone()); - manifest["artifacts"][0][extension_name_b] = Value::String(extension_value_b.clone()); - manifest["artifacts"][0][extension_name_a] = Value::String(extension_value_a.clone()); - - let assessment = assess_server_intake(&serialize_manifest(&manifest), &payloads) + 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!([ @@ -1584,14 +1644,7 @@ fn server_manifest_v1_retains_only_versioned_opaque_extensions_deterministically assert_eq!(public["topology"]["extensions"], expected); assert_eq!(public["artifacts"][0]["extensions"], expected); - let mut reordered = manifest_value(&manifest_json); - reordered[extension_name_a] = Value::String(extension_value_a.clone()); - reordered[extension_name_b] = Value::String(extension_value_b.clone()); - reordered["topology"][extension_name_a] = Value::String(extension_value_a.clone()); - reordered["topology"][extension_name_b] = Value::String(extension_value_b.clone()); - reordered["artifacts"][0][extension_name_a] = Value::String(extension_value_a.clone()); - reordered["artifacts"][0][extension_name_b] = Value::String(extension_value_b.clone()); - let reordered_assessment = assess_server_intake(&serialize_manifest(&reordered), &payloads) + let reordered_assessment = assess_server_intake(&alpha_then_beta, &payloads) .expect("extension arrival order does not change normalized output"); assert_eq!(assessment, reordered_assessment); } From 8daad4e9f38918c62b53bf7123257a42507ab37c Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 02:39:05 -0400 Subject: [PATCH 269/422] fix(sccm): keep future source handles production-only --- .../src/sccm/server/windows/intake.rs | 8 +++++--- .../cmtraceopen-parser/tests/sccm_server_intake.rs | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 139722e65..253398414 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -790,7 +790,7 @@ fn normalize_artifact( 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) + || !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( @@ -1561,7 +1561,7 @@ fn safe_manifest_artifact_id(value: &str, synthetic_fixture: bool) -> bool { opaque_sha256_handle(value, "cmtraceopen.artifact.sha256.v1:") } -fn safe_source_id(value: &str, allow_unknown: bool) -> bool { +fn safe_source_id(value: &str, allow_unknown: bool, synthetic_fixture: bool) -> bool { matches!( value, "server-sitecomp" @@ -1572,7 +1572,9 @@ fn safe_source_id(value: &str, allow_unknown: bool) -> bool { | "server-dp-distribution" | "server-sup-sync" | "unknown-db-supplement" - ) || (allow_unknown && opaque_sha256_handle(value, "cmtraceopen.source.sha256.v1:")) + ) || (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 { diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index 3eebf2ee6..f7c645e43 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -1542,6 +1542,20 @@ fn server_intake_rejects_unversioned_future_unsupported_source_labels() { ); } +#[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 [ From 976ec2a1d25104d3bfe7e0657091e6bb0d87660b Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 02:47:10 -0400 Subject: [PATCH 270/422] fix(sccm): honor declared server payload encoding --- .../src/sccm/server/windows/intake.rs | 7 ++++- .../tests/sccm_server_intake.rs | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 253398414..90f136a8d 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -1153,7 +1153,12 @@ fn decode_server_payload( .map(Some) .map_err(|_| SccmServerIntakeError::InvalidPayloadEncoding) } - "windows-1252" => Ok(Some(encoding_rs::WINDOWS_1252.decode(bytes).0.into_owned())), + "windows-1252" => Ok(Some( + encoding_rs::WINDOWS_1252 + .decode_without_bom_handling(bytes) + .0 + .into_owned(), + )), "unknown" => Ok(None), _ => Err(SccmServerIntakeError::InvalidPayloadEncoding), } diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index f7c645e43..cb0f5aaa9 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -1487,6 +1487,37 @@ fn server_intake_decodes_declared_windows_1252_ccm_payloads() { assert!(assessment.evidence[0].message.contains("café €")); } +#[test] +fn server_intake_declared_windows_1252_does_not_sniff_a_conflicting_bom() { + let (manifest_json, mut payloads) = load_bundle("multiline"); + let mut manifest = manifest_value(&manifest_json); + let marker = b"SYNTHETIC FIXTURE"; + let marker_end = payloads[0] + .bytes + .windows(marker.len()) + .position(|window| window == marker) + .expect("fixture contains its synthetic marker") + + marker.len(); + let mut declared_windows_1252 = vec![0xef, 0xbb, 0xbf]; + declared_windows_1252.extend_from_slice(&payloads[0].bytes[..marker_end]); + declared_windows_1252.extend_from_slice(b" \xc3\xa9"); + declared_windows_1252.extend_from_slice(&payloads[0].bytes[marker_end..]); + payloads[0].bytes = declared_windows_1252; + manifest["artifacts"][0]["encoding"] = Value::String("windows-1252".to_owned()); + manifest["artifacts"][0]["bytesCopied"] = Value::from(payloads[0].bytes.len() as u64); + + let assessment = assess_server_intake(&serialize_manifest(&manifest), &payloads) + .expect("the declared Windows-1252 contract wins over payload BOM bytes"); + + assert_eq!(assessment.evidence.len(), 1); + assert!( + assessment.evidence[0] + .message + .contains("SYNTHETIC FIXTURE é"), + "BOM sniffing must not reinterpret a manifest-declared Windows-1252 payload as UTF-8" + ); +} + #[test] fn server_intake_keeps_unknown_encoding_as_unsupported_coverage() { let (manifest_json, payloads) = load_bundle("multiline"); From c93ef45891a1cd2a19686d99495056f25890df31 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 03:35:52 -0400 Subject: [PATCH 271/422] test(sccm): drive site core from server intake --- .../tests/sccm_server_site_core.rs | 585 ++++++++++++++++++ 1 file changed, 585 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/sccm_server_site_core.rs 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..f01224de1 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -0,0 +1,585 @@ +use cmtraceopen_parser::sccm::server::windows::{ + analyze_site_core, assess_server_intake, SccmServerArtifactPayload, SccmServerIntakeAssessment, + SccmSiteCoreConfidence, SccmSiteCorePhase, SccmSiteCoreState, +}; +use cmtraceopen_parser::sccm::{SccmCoverageState, SccmFindingClass, SccmTimeOrderingState}; +use serde_json::{json, Value}; + +const HEALTHY_SITECOMP: &str = include_str!( + "fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log" +); +const HEALTHY_STATUS: &str = include_str!( + "fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/status/current/statmgr.log" +); +const COMPONENT_FAILURE: &str = include_str!( + "fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log" +); +const INBOX_BACKLOG: &str = include_str!( + "fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log" +); +const STATUS_FAILURE_SITECOMP: &str = include_str!( + "fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log" +); +const STATUS_FAILURE_STATUS: &str = include_str!( + "fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-core/status/current/statmgr.log" +); +const RECOVERY_SITECOMP: &str = include_str!( + "fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log" +); +const RECOVERY_STATUS: &str = include_str!( + "fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-core/status/current/statmgr.log" +); +const CONTRADICTORY_SITECOMP: &str = include_str!( + "fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log" +); +const CONTRADICTORY_STATUS: &str = include_str!( + "fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/status/current/statmgr.log" +); +const ROTATION_CURRENT_FRAGMENT: &str = include_str!( + "fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log" +); +const ROTATION_LO_FRAGMENT: &str = include_str!( + "fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/lo_/sitecomp.lo_" +); + +#[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, + 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", + 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", + 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", + 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", + 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_", + 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 relative_path(&self) -> Option { + self.content.map(|_| { + let rotation = match self.rotation_kind { + "current" => "current", + "lo_" => "lo_", + 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, + "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 { + 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": sources.iter().map(Source::manifest_artifact).collect::>(), + }); + 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 classifications(assessment: &SccmServerIntakeAssessment) -> Vec> { + analyze_site_core(assessment) + .results + .into_iter() + .map(|result| result.finding_class) + .collect() +} + +#[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 + .iter() + .all(|gap| gap.artifact_id != "b-sitecomp")); + assert!(analysis.findings.is_empty()); + assert!(analysis.artifact_requests.is_empty()); +} + +#[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(), 1); + assert_eq!(component.findings[0].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.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 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 mut split_hosts = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + split_hosts + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-status") + .expect("status artifact") + .producer_host_handle = Some("synthetic:host:site-02".to_owned()); + let split = analyze_site_core(&split_hosts); + assert!(split.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + || result.confidence != SccmSiteCoreConfidence::High + })); + assert!(split.results.iter().all(|result| { + result.transaction_key.producer_host_handle == "synthetic:host:site-01" + || result.transaction_key.producer_host_handle == "synthetic:host:site-02" + })); +} + +#[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 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), + ("profile", unknown_profile), + ("coverage", denied), + ("fragment", incomplete_fragment), + ("cap", capped), + ("time", invalid_time), + ] { + let analysis = analyze_site_core(&assessment); + 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 + })); + } +} + +#[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!(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!(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 + .iter() + .all(|finding| finding.finding.class != SccmFindingClass::ConfirmedFailure)); +} + +#[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!(classifications(&assessment) + .into_iter() + .all(|class| class != Some(SccmFindingClass::ConfirmedFailure))); +} From 55ad437343b48f41ba0612caf3f63556c31efdf1 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 03:49:33 -0400 Subject: [PATCH 272/422] fix(sccm): harden client intake and CCM ambiguity --- crates/cmtraceopen-parser/src/parser/ccm.rs | 145 +++++++++++++++++- .../src/sccm/client/intake.rs | 97 +++++++++++- .../tests/sccm_client_intake.rs | 39 +++++ 3 files changed, 276 insertions(+), 5 deletions(-) diff --git a/crates/cmtraceopen-parser/src/parser/ccm.rs b/crates/cmtraceopen-parser/src/parser/ccm.rs index 6e6238c2d..d2b71bbb4 100644 --- a/crates/cmtraceopen-parser/src/parser/ccm.rs +++ b/crates/cmtraceopen-parser/src/parser/ccm.rs @@ -14,6 +14,8 @@ use super::severity::detect_severity_from_text; use crate::models::log_entry::{LogEntry, LogFormat, ParserSpecialization, Severity}; use std::sync::OnceLock; +const CCM_RECORD_OPENER: &str = " CcmSca let mut errors = 0u32; let mut id_counter = 0u64; 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"); + + if newest_nested_opener(content, full_match.start(), full_match.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 records, + &mut id_counter, + &mut errors, + ); + 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( @@ -426,6 +452,7 @@ fn scan_ccm_content(content: &str, file_path: &str, mode: CcmScanMode) -> CcmSca } cursor = full_match.end(); + search_cursor = full_match.end(); matched_any = true; } @@ -462,6 +489,28 @@ fn scan_ccm_content(content: &str, file_path: &str, mode: CcmScanMode) -> CcmSca CcmScan { records, errors } } +fn newest_nested_opener( + content: &str, + full_match_start: usize, + full_match_end: usize, +) -> Option { + // Only physical-line openers can delimit recovery records. Scan newest + // first so an adversarial run of partial openers is discarded in one pass. + let nested_search_start = full_match_start.checked_add(CCM_RECORD_OPENER.len())?; + content + .get(nested_search_start..full_match_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())?; @@ -1011,6 +1060,96 @@ mod tests { 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 " + ), + 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!( diff --git a/crates/cmtraceopen-parser/src/sccm/client/intake.rs b/crates/cmtraceopen-parser/src/sccm/client/intake.rs index b1851c330..52059235c 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -1,8 +1,13 @@ use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; use chrono::{DateTime, SecondsFormat, Utc}; -use serde::{de::Error as _, ser::Error as _, Deserialize, Deserializer, Serialize, Serializer}; +use serde::{ + de::{Error as _, IgnoredAny, SeqAccess, Visitor}, + ser::Error as _, + Deserialize, Deserializer, Serialize, Serializer, +}; use thiserror::Error; use crate::sccm::catalog::{ @@ -12,6 +17,13 @@ use crate::sccm::{ SccmArtifact, SccmCoverageState, SccmRole, SccmRotation, SCCM_DIAGNOSTICS_SCHEMA_VERSION, }; +/// Maximum artifact declarations admitted by one SCCM client intake bundle. +/// +/// This is the shared v1 manifest ceiling. Validate 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; @@ -172,12 +184,87 @@ pub struct SccmClientIntakeArtifact { pub fragment_complete: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SccmClientIntakeBundle { pub artifacts: Vec, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SccmClientIntakeBundleWire { + #[serde(deserialize_with = "deserialize_bounded_client_artifacts")] + artifacts: Vec, +} + +impl<'de> Deserialize<'de> for SccmClientIntakeBundle { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = SccmClientIntakeBundleWire::deserialize(deserializer)?; + Ok(Self { + artifacts: wire.artifacts, + }) + } +} + +fn deserialize_bounded_client_artifacts<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct BoundedArtifactVisitor; + + impl<'de> Visitor<'de> for BoundedArtifactVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "at most {MAX_SCCM_CLIENT_INTAKE_ARTIFACTS} SCCM client artifacts" + ) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + if sequence + .size_hint() + .is_some_and(|size| size > MAX_SCCM_CLIENT_INTAKE_ARTIFACTS) + { + return Err(A::Error::custom( + "client intake artifact count exceeds the supported limit", + )); + } + + let initial_capacity = sequence + .size_hint() + .unwrap_or_default() + .min(MAX_SCCM_CLIENT_INTAKE_ARTIFACTS); + let mut artifacts = Vec::with_capacity(initial_capacity); + while artifacts.len() < MAX_SCCM_CLIENT_INTAKE_ARTIFACTS { + let Some(artifact) = sequence.next_element()? else { + return Ok(artifacts); + }; + artifacts.push(artifact); + } + + if sequence.next_element::()?.is_some() { + return Err(A::Error::custom( + "client intake artifact count exceeds the supported limit", + )); + } + + Ok(artifacts) + } + } + + deserializer.deserialize_seq(BoundedArtifactVisitor) +} + #[derive(Debug, Clone, PartialEq)] pub struct SccmClientIntakeFragment { pub artifact_id: String, @@ -489,6 +576,8 @@ impl SccmClientIntakeAssessment { #[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")] @@ -804,6 +893,10 @@ fn unsupported_as_intake_artifact( } fn validate_bundle(bundle: &SccmClientIntakeBundle) -> Result<(), SccmClientIntakeError> { + if bundle.artifacts.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(); diff --git a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs index 91c287e5a..78afc7ce9 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs @@ -7,6 +7,7 @@ use cmtraceopen_parser::sccm::{ SccmClientIntakeArtifact, SccmClientIntakeAssessment, SccmClientIntakeBundle, SccmClientIntakeCoverageGap, SccmClientIntakeError, SccmClientIntakeFragment, SccmClientUnsupportedArtifact, SccmCoverageState, SccmRole, SccmRotation, SccmUnknownRotation, + MAX_SCCM_CLIENT_INTAKE_ARTIFACTS, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -426,6 +427,44 @@ fn synthetic_marker( artifact } +#[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 }) + .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 artifact = serde_json::to_value(synthetic_artifact("wire-limit", "PolicyAgent.log")) + .expect("synthetic intake artifact serializes"); + let boundary = serde_json::json!({ + "artifacts": vec![artifact.clone(); MAX_SCCM_CLIENT_INTAKE_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 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}" + ); +} + /// 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 From b9c2653f943b303d70ffbc858f4a5e701cf8bdf6 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 03:49:56 -0400 Subject: [PATCH 273/422] feat(sccm): analyze site core from server intake --- .../src/sccm/server/windows/mod.rs | 2 + .../src/sccm/server/windows/site_core.rs | 1386 +++++++++++++++++ .../tests/sccm_server_site_core.rs | 95 ++ 3 files changed, 1483 insertions(+) create mode 100644 crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs index 7a8eaf309..68e2c3952 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs @@ -1,7 +1,9 @@ mod catalog; mod intake; mod management_point; +mod site_core; pub use catalog::*; pub use intake::*; pub use management_point::*; +pub use site_core::*; 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..95eaa5915 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs @@ -0,0 +1,1386 @@ +//! 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 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::{ + 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 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, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSiteCoreArtifactRequest { + pub logical_name: String, + pub role: SccmRole, + pub reason_code: String, + pub basenames: Vec, + pub rotations: 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 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 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 expected_component(self) -> &'static str { + match self { + Self::Component => "SMS_SITE_COMPONENT_MANAGER", + Self::Status => "SMS_STATUS_MANAGER", + } + } + + 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, +} + +struct SiteCoreContext<'a> { + sources: BTreeMap<&'a str, AdmittedSource<'a>>, + evidence_identity_is_unique: Vec, + coverage_gaps: Vec, +} + +impl<'a> SiteCoreContext<'a> { + fn new(intake: &'a SccmServerIntakeAssessment) -> Self { + let sources = admitted_sources(intake); + let coverage_gaps = collect_coverage_gaps(intake, &sources); + Self { + sources, + evidence_identity_is_unique: unique_evidence_identities(&intake.evidence), + coverage_gaps, + } + } +} + +pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAnalysis { + let context = SiteCoreContext::new(intake); + let mut grouped = BTreeMap::>::new(); + for (position, evidence) in intake.evidence.iter().enumerate() { + let Some(source) = context.sources.get(evidence.reference.artifact_id.as_str()) else { + continue; + }; + if !source.fact_eligible + || evidence.role != SccmRole::SiteServer + || !context.evidence_identity_is_unique[position] + || !reference_is_complete(evidence) + { + continue; + } + if let Some(fact) = parse_fact(evidence, source, &intake.topology.site_handle) { + grouped.entry(fact.key.clone()).or_default().push(fact); + } + } + + 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 mut 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); + } else { + reduced.finding_class = None; + } + } + 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.sort_by(|left, right| left.observation_id.cmp(&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, +) -> 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 artifact.producer_role != SccmRole::SiteServer + || artifact.workflow_subject_role.is_some() + || occurrences.get(artifact.artifact_id.as_str()) != Some(&1) + { + return None; + } + let shape_valid = source_shape_is_valid(artifact, group); + Some(( + artifact.artifact_id.as_str(), + AdmittedSource { + artifact, + group, + fact_eligible: shape_valid && source_carries_facts(artifact), + }, + )) + }) + .collect() +} + +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 => classified.logical_name == "sitecomp", + SiteCoreGroup::Status => classified.logical_name == "statmgr", + }; + validated_logical_source + && artifact.source_id == group.source_id() + && artifact.family == group.family() + && artifact.rotation.is_some() + && 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 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 + .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 gaps = Vec::new(); + for source in sources.values() { + if source.fact_eligible || absent_default_is_superseded(source.artifact, sources) { + continue; + } + let state = if source.artifact.state == SccmCoverageState::Captured { + if source.artifact.fragment_complete == Some(false) + || source.artifact.truncated == Some(true) + { + SccmCoverageState::ParseFailed + } else { + SccmCoverageState::Unsupported + } + } else { + source.artifact.state.clone() + }; + gaps.push(SccmSiteCoreCoverageGap { + artifact_id: source.artifact.artifact_id.clone(), + source_id: source.artifact.source_id.clone(), + state, + diagnostic_meaning: SccmSiteCoreDiagnosticMeaning::CoverageOnly, + }); + } + 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))) + }); + gaps.dedup_by(|left, right| { + left.artifact_id == right.artifact_id + && left.source_id == right.source_id + && left.state == right.state + }); + + // An assessment may be externally reordered, but it must not manufacture a + // gap for an artifact that does not exist in the assessment. + let artifact_ids = intake + .artifacts + .iter() + .map(|artifact| artifact.artifact_id.as_str()) + .collect::>(); + gaps.retain(|gap| artifact_ids.contains(gap.artifact_id.as_str())); + gaps +} + +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, + } + } +} + +fn parse_fact( + evidence: &SccmEvidence, + source: &AdmittedSource<'_>, + site_handle: &str, +) -> Option { + if evidence.component.as_deref() != Some(source.group.expected_component()) { + return None; + } + let message = evidence.message.as_str(); + if token_value(message, "profileId")? != SCCM_SITE_CORE_PROFILE_ID + || token_value(message, "profileVersion")? != SCCM_SITE_CORE_PROFILE_VERSION.to_string() + || site_handle != "synthetic:site:lab" + || token_value(message, "site")? != "LAB" + { + return None; + } + + let component_id = validated_identifier(&token_value(message, "componentId")?)?; + let work_item_id = validated_identifier(&token_value(message, "workItemId")?)?; + let marker = status_marker(&token_value(message, "statusId")?)?; + if marker.group != source.group + || token_value(message, "outcome")? != marker.outcome.token() + || token_value(message, "terminal")? != if marker.terminal { "true" } else { "false" } + { + return None; + } + + Some(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()?, + 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 = facts + .iter() + .any(|fact| fact.marker.outcome == FactOutcome::Deferred); + 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 = format!( + "site-core:{}:{}:{}:{}", + 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 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", + Some(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 vec![recapture_request(source.artifact, Some(key))]; + } + 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", + Some(key), + )]; + } + Vec::new() +} + +fn status_request( + reason_code: &str, + key: Option<&SccmSiteCoreTransactionKey>, +) -> SccmSiteCoreArtifactRequest { + SccmSiteCoreArtifactRequest { + logical_name: SCCM_SITE_CORE_STATUS_GROUP.to_owned(), + role: SccmRole::SiteServer, + reason_code: reason_code.to_owned(), + basenames: vec!["statmgr.log".to_owned(), "statmgr.lo_".to_owned()], + rotations: vec!["current".to_owned(), "loUnderscore".to_owned()], + max_artifacts: 2, + max_bytes_per_artifact: None, + scope: SccmSiteCoreRequestScope { + producer_host_handle: key.map(|key| key.producer_host_handle.clone()), + component_id: key.map(|key| key.component_id.clone()), + work_item_id: key.map(|key| key.work_item_id.clone()), + rotation_lineage_handle: None, + }, + } +} + +fn recapture_request( + artifact: &SccmServerArtifactAssessment, + key: Option<&SccmSiteCoreTransactionKey>, +) -> SccmSiteCoreArtifactRequest { + 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); + SccmSiteCoreArtifactRequest { + logical_name: artifact.source_id.clone(), + role: SccmRole::SiteServer, + reason_code: "capped-before-next-phase".to_owned(), + basenames: artifact.original_basename.clone().into_iter().collect(), + rotations: artifact + .rotation + .as_ref() + .and_then(rotation_name) + .into_iter() + .collect(), + max_artifacts: 1, + max_bytes_per_artifact: Some(bounded), + scope: SccmSiteCoreRequestScope { + producer_host_handle: key + .map(|key| key.producer_host_handle.clone()) + .or_else(|| artifact.producer_host_handle.clone()), + component_id: key.map(|key| key.component_id.clone()), + work_item_id: key.map(|key| key.work_item_id.clone()), + rotation_lineage_handle: Some(artifact.rotation_lineage_handle.clone()), + }, + } +} + +fn coverage_observations( + gaps: &[SccmSiteCoreCoverageGap], + context: &SiteCoreContext<'_>, +) -> Vec { + gaps.iter() + .filter(|gap| { + matches!( + gap.state, + SccmCoverageState::Capped | SccmCoverageState::ParseFailed + ) + }) + .map(|gap| { + let request = context + .sources + .get(gap.artifact_id.as_str()) + .map(|source| match gap.state { + SccmCoverageState::Capped => recapture_request(source.artifact, None), + _ => complete_source_request(source.artifact), + }) + .into_iter() + .collect(); + SccmSiteCoreObservation { + observation_id: format!("site-core:coverage:{}", gap.artifact_id), + state: SccmSiteCoreState::ParseGap, + finding_class: SccmFindingClass::InsufficientEvidence, + confidence: SccmSiteCoreConfidence::None, + coverage_gap_artifact_ids: vec![gap.artifact_id.clone()], + next_artifacts: request, + } + }) + .collect() +} + +fn complete_source_request(artifact: &SccmServerArtifactAssessment) -> SccmSiteCoreArtifactRequest { + SccmSiteCoreArtifactRequest { + logical_name: artifact.source_id.clone(), + role: SccmRole::SiteServer, + reason_code: "complete-logical-record-required".to_owned(), + basenames: artifact.original_basename.clone().into_iter().collect(), + rotations: artifact + .rotation + .as_ref() + .and_then(rotation_name) + .into_iter() + .collect(), + max_artifacts: 1, + max_bytes_per_artifact: None, + scope: SccmSiteCoreRequestScope { + producer_host_handle: artifact.producer_host_handle.clone(), + component_id: None, + work_item_id: None, + rotation_lineage_handle: Some(artifact.rotation_lineage_handle.clone()), + }, + } +} + +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(format!("finding:{}", result.result_id)) + .class(class) + .phase(SccmPhase::Unknown( + result + .last_successful_phase + .unwrap_or(SccmSiteCorePhase::ComponentStart) + .serialized_name() + .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 finding = SccmFindingBuilder::new(format!("finding:{}", observation.observation_id)) + .class(observation.finding_class.clone()) + .phase(SccmPhase::Unknown("siteCoreCoverage".to_owned())) + .role(SccmRole::SiteServer) + .severity(Severity::Warning) + .confidence(shared_confidence(observation.confidence)) + .title("Site core coverage gap") + .summary("The source is incomplete and cannot establish a component outcome.") + .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.basenames.iter()) + .filter_map(|basename| { + let classified = classify_artifact_name(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 validated_identifier(value: &str) -> Option { + (!value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))) + .then(|| value.to_owned()) +} + +fn reference_is_complete(evidence: &SccmEvidence) -> bool { + 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 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, + } +} diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index f01224de1..c440d6ad3 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -41,6 +41,20 @@ const ROTATION_CURRENT_FRAGMENT: &str = include_str!( const ROTATION_LO_FRAGMENT: &str = include_str!( "fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/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", +); #[derive(Clone)] struct Source<'a> { @@ -50,6 +64,7 @@ struct Source<'a> { 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, @@ -69,6 +84,7 @@ impl<'a> Source<'a> { 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", @@ -88,6 +104,7 @@ impl<'a> Source<'a> { 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", @@ -107,6 +124,7 @@ impl<'a> Source<'a> { 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", @@ -126,6 +144,7 @@ impl<'a> Source<'a> { path_fingerprint: "synthetic:path:a-site", lineage_id: "sitecomp-a", rotation_kind: "current", + rotation_value: None, content: None, capture_state: "absent", configured_state: "defaultCandidate", @@ -154,6 +173,7 @@ impl<'a> Source<'a> { 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", @@ -165,11 +185,20 @@ impl<'a> Source<'a> { } } + 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!( @@ -208,6 +237,7 @@ impl<'a> Source<'a> { }, "rotation": { "kind": self.rotation_kind, + "value": self.rotation_value, "lineageId": self.lineage_id, }, "captureState": self.capture_state, @@ -315,6 +345,42 @@ fn configured_nondefault_sources_supersede_absent_default_candidates() { 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(&[ @@ -431,6 +497,24 @@ fn unrelated_same_minute_components_and_producer_hosts_never_merge() { result.transaction_key.producer_host_handle == "synthetic:host:site-01" || result.transaction_key.producer_host_handle == "synthetic:host:site-02" })); + + let mut foreign_gap = assess(&[Source::sitecomp(HEALTHY_SITECOMP), Source::absent_status()]); + foreign_gap + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-status") + .expect("status artifact") + .producer_host_handle = Some("synthetic:host:site-02".to_owned()); + let foreign_gap_analysis = analyze_site_core(&foreign_gap); + assert_eq!(foreign_gap_analysis.results.len(), 1); + assert!(foreign_gap_analysis.results[0] + .coverage_gap_artifact_ids + .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"))); } #[test] @@ -469,6 +553,16 @@ fn encoding_profile_coverage_fragment_cap_and_time_provenance_fail_closed() { .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(), @@ -486,6 +580,7 @@ fn encoding_profile_coverage_fragment_cap_and_time_provenance_fail_closed() { ("profile", unknown_profile), ("coverage", denied), ("fragment", incomplete_fragment), + ("content", missing_content_provenance), ("cap", capped), ("time", invalid_time), ] { From eb03235840614f8b451f8816180afac8d74c86ca Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 03:56:15 -0400 Subject: [PATCH 274/422] docs(sccm): make client intake ceiling authoritative (#319) --- crates/cmtraceopen-parser/src/sccm/client/intake.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/intake.rs b/crates/cmtraceopen-parser/src/sccm/client/intake.rs index 52059235c..64eb53a8f 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -19,7 +19,10 @@ use crate::sccm::{ /// Maximum artifact declarations admitted by one SCCM client intake bundle. /// -/// This is the shared v1 manifest ceiling. Validate it before allocating +/// 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; From 2d40321f4118863099a52002f978bc858bc4c10a Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 03:59:59 -0400 Subject: [PATCH 275/422] test(sccm): strengthen site core isolation assertions --- .../tests/sccm_server_site_core.rs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index c440d6ad3..afddfa6e0 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -417,6 +417,11 @@ fn terminal_component_and_status_outcomes_require_exact_cited_facts() { 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); } @@ -489,14 +494,23 @@ fn unrelated_same_minute_components_and_producer_hosts_never_merge() { .expect("status artifact") .producer_host_handle = Some("synthetic:host:site-02".to_owned()); 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().all(|result| { - result.transaction_key.producer_host_handle == "synthetic:host:site-01" - || result.transaction_key.producer_host_handle == "synthetic:host:site-02" - })); + 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:site-02" })); let mut foreign_gap = assess(&[Source::sitecomp(HEALTHY_SITECOMP), Source::absent_status()]); foreign_gap From 074b41275e76c0aeb6668906cd37011e6cd30731 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 04:09:14 -0400 Subject: [PATCH 276/422] fix(sccm): address client intake review findings --- crates/cmtraceopen-parser/src/parser/ccm.rs | 35 ++++++++++++++++--- .../src/sccm/client/intake.rs | 4 +-- .../tests/sccm_client_intake.rs | 16 +++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/crates/cmtraceopen-parser/src/parser/ccm.rs b/crates/cmtraceopen-parser/src/parser/ccm.rs index d2b71bbb4..ee9429b63 100644 --- a/crates/cmtraceopen-parser/src/parser/ccm.rs +++ b/crates/cmtraceopen-parser/src/parser/ccm.rs @@ -387,7 +387,11 @@ fn scan_ccm_content(content: &str, file_path: &str, mode: CcmScanMode) -> CcmSca .get(0) .expect("CCM regex captures always include the full match"); - if newest_nested_opener(content, full_match.start(), full_match.end()).is_some() + 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 @@ -492,13 +496,15 @@ fn scan_ccm_content(content: &str, file_path: &str, mode: CcmScanMode) -> CcmSca fn newest_nested_opener( content: &str, full_match_start: usize, - full_match_end: usize, + message_end: usize, ) -> Option { - // Only physical-line openers can delimit recovery records. Scan newest - // first so an adversarial run of partial openers is discarded in one pass. + // 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..full_match_end)? + .get(nested_search_start..message_end)? .rmatch_indices(CCM_RECORD_OPENER) .find_map(|(offset, _)| { let absolute = nested_search_start + offset; @@ -1124,6 +1130,25 @@ mod tests { assert_eq!(records[0].line_end, 1); } + #[test] + fn logical_scanner_ignores_line_start_opener_inside_attribute_value() { + let text = concat!( + "", + "" + ); + + 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 MAX_SCCM_CLIENT_INTAKE_ARTIFACTS) { return Err(A::Error::custom( - "client intake artifact count exceeds the supported limit", + SccmClientIntakeError::ArtifactLimitExceeded, )); } @@ -257,7 +257,7 @@ where if sequence.next_element::()?.is_some() { return Err(A::Error::custom( - "client intake artifact count exceeds the supported limit", + SccmClientIntakeError::ArtifactLimitExceeded, )); } diff --git a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs index 78afc7ce9..e1f791db0 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs @@ -465,6 +465,22 @@ fn intake_wire_rejects_more_than_the_v1_artifact_limit_while_deserializing() { ); } +#[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}" + ); +} + /// 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 From 0771c2cd2a9ff3b443ab3a96ece1992c26830330 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 04:16:46 -0400 Subject: [PATCH 277/422] test(sccm): expose site core review blockers --- .../tests/sccm_server_site_core.rs | 406 +++++++++++++++++- 1 file changed, 404 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index afddfa6e0..91cd8fb27 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -1,9 +1,14 @@ use cmtraceopen_parser::sccm::server::windows::{ analyze_site_core, assess_server_intake, SccmServerArtifactPayload, SccmServerIntakeAssessment, - SccmSiteCoreConfidence, SccmSiteCorePhase, SccmSiteCoreState, + SccmSiteCoreAnalysis, SccmSiteCoreConfidence, SccmSiteCorePhase, SccmSiteCoreState, +}; +use cmtraceopen_parser::sccm::{ + SccmCoverageState, SccmFindingClass, SccmRole, SccmRotation, SccmTimeOrderingState, + SccmUnknownRotation, }; -use cmtraceopen_parser::sccm::{SccmCoverageState, SccmFindingClass, SccmTimeOrderingState}; 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-core/sitecomp/current/sitecomp.log" @@ -55,6 +60,12 @@ const SUCCESS_AFTER_FAILURE: &str = concat!( "\n", "\n", ); +const DEFERRED_THEN_ACCEPTED: &str = concat!( + "\n", + "\n", + "\n", + "\n", +); #[derive(Clone)] struct Source<'a> { @@ -288,6 +299,63 @@ fn classifications(assessment: &SccmServerIntakeAssessment) -> Vec 0 && request.max_artifacts <= 2 + })); +} + +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(&[ @@ -692,3 +760,337 @@ fn no_provenance_mutation_can_reintroduce_a_confirmed_failure() { .into_iter() .all(|class| class != Some(SccmFindingClass::ConfirmedFailure))); } + +#[test] +fn rejected_role_subject_and_duplicate_sources_become_explicit_parse_gaps() { + 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_explicit_gap_and_request( + &analyze_site_core(&wrong_role), + "z-site-status", + "server-status", + ); + + 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_explicit_gap_and_request( + &analyze_site_core(&wrong_subject), + "sitecomp-current", + "server-sitecomp", + ); + + 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_explicit_gap_and_request( + &analyze_site_core(&duplicate), + "sitecomp-current", + "server-sitecomp", + ); +} + +#[test] +fn colliding_evidence_identities_are_parse_gaps_not_silent_drops() { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + let duplicate = assessment.evidence[0].clone(); + assessment.evidence.push(duplicate); + + let analysis = analyze_site_core(&assessment); + assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); + assert!(analysis.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + || result.confidence != SccmSiteCoreConfidence::High + })); +} + +#[test] +fn closed_profile_schema_rejects_arbitrary_keys_and_retains_safe_unknown_facts() { + let mut arbitrary_work = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + for evidence in &mut arbitrary_work.evidence { + evidence.message = evidence + .message + .replace("workItemId=SC-HEALTH-001", "workItemId=ARBITRARY-001"); + } + let arbitrary = analyze_site_core(&arbitrary_work); + assert!(arbitrary.results.is_empty()); + assert!(!arbitrary.unlinked_observations.is_empty()); + 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 mut unknown_status = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + let rejected_id = unknown_status.evidence[0].evidence_id.clone(); + unknown_status.evidence[0].message = unknown_status.evidence[0] + .message + .replace("SC_COMPONENT_START_OK", "SC_UNREVIEWED_STATUS"); + let unknown = analyze_site_core(&unknown_status); + let unknown_wire = serde_json::to_string(&unknown).expect("analysis serializes"); + assert!(unknown_wire.contains(&rejected_id)); + assert!(unknown.unlinked_observations.iter().any(|observation| { + observation.finding_class == SccmFindingClass::Symptom + || observation.finding_class == SccmFindingClass::InsufficientEvidence + })); +} + +#[test] +fn every_required_source_coverage_state_emits_insufficient_evidence_and_a_request() { + for state in [ + SccmCoverageState::Absent, + SccmCoverageState::AccessDenied, + SccmCoverageState::Skipped, + SccmCoverageState::Unsupported, + ] { + let mut assessment = assess(&[ + Source::sitecomp(HEALTHY_SITECOMP), + Source::status(HEALTHY_STATUS), + ]); + assessment + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("sitecomp artifact") + .state = state.clone(); + assessment + .coverage + .iter_mut() + .find(|coverage| coverage.source_id == "server-sitecomp") + .expect("sitecomp coverage") + .state = state.clone(); + + 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!(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!(analysis.results.iter().all(|result| { + result.finding_class.is_some() || result.state == SccmSiteCoreState::Healthy + })); + assert!(!analysis.unlinked_observations.is_empty()); + assert!(!analysis.artifact_requests.is_empty()); +} + +#[test] +fn committed_site_core_corpus_exactly_matches_every_serialized_output() { + for scenario in [ + "healthy", + "component-failure", + "inbox-backlog", + "status-processing-failure", + "recovery", + "contradictory", + "rotation-boundary", + "incomplete", + "malformed", + ] { + let (assessment, expected) = load_corpus_scenario(scenario); + 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_explicit_gap_and_request(&rejected, "sitecomp-current", "server-sitecomp"); + assert!(rejected.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + || result.confidence != SccmSiteCoreConfidence::High + })); + + 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, + })); + let unknown = analyze_site_core(&unknown_rotation); + assert!(unknown.artifact_requests.iter().all(|request| { + serde_json::to_value(request).expect("request serializes")["candidates"] + .as_array() + .is_some_and(|candidates| !candidates.is_empty()) + })); +} + +#[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!(analysis.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + || result.confidence != SccmSiteCoreConfidence::High + })); + assert!(!analysis.coverage_gaps.is_empty()); + assert!(!analysis.unlinked_observations.is_empty()); + assert!(!analysis.artifact_requests.is_empty()); +} From 3b461a280e9aadfc278f0833ceda14b7c903206c Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 04:12:19 -0400 Subject: [PATCH 278/422] feat(sccm): preserve client capture gap coverage (#319) --- .../src/sccm/client/intake.rs | 235 ++++++++++++- .../tests/sccm_client_intake.rs | 332 +++++++++++++++++- 2 files changed, 556 insertions(+), 11 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/intake.rs b/crates/cmtraceopen-parser/src/sccm/client/intake.rs index 705f43af4..6aa2f520f 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -187,10 +187,30 @@ pub struct SccmClientIntakeArtifact { pub fragment_complete: 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, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +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(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] 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. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capture_gaps: Vec, } #[derive(Deserialize)] @@ -198,6 +218,8 @@ pub struct SccmClientIntakeBundle { struct SccmClientIntakeBundleWire { #[serde(deserialize_with = "deserialize_bounded_client_artifacts")] artifacts: Vec, + #[serde(default, deserialize_with = "deserialize_bounded_client_capture_gaps")] + capture_gaps: Vec, } impl<'de> Deserialize<'de> for SccmClientIntakeBundle { @@ -206,12 +228,76 @@ impl<'de> Deserialize<'de> for SccmClientIntakeBundle { D: Deserializer<'de>, { let wire = SccmClientIntakeBundleWire::deserialize(deserializer)?; + if wire.artifacts.len().saturating_add(wire.capture_gaps.len()) + > MAX_SCCM_CLIENT_INTAKE_ARTIFACTS + { + return Err(D::Error::custom( + "client intake artifact count exceeds the supported limit", + )); + } Ok(Self { artifacts: wire.artifacts, + capture_gaps: wire.capture_gaps, }) } } +fn deserialize_bounded_client_capture_gaps<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct BoundedCaptureGapVisitor; + + impl<'de> Visitor<'de> for BoundedCaptureGapVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "at most {MAX_SCCM_CLIENT_INTAKE_ARTIFACTS} SCCM client capture gaps" + ) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + if sequence + .size_hint() + .is_some_and(|size| size > MAX_SCCM_CLIENT_INTAKE_ARTIFACTS) + { + return Err(A::Error::custom( + "client intake capture-gap count exceeds the supported limit", + )); + } + + let initial_capacity = sequence + .size_hint() + .unwrap_or_default() + .min(MAX_SCCM_CLIENT_INTAKE_ARTIFACTS); + let mut capture_gaps = Vec::with_capacity(initial_capacity); + while capture_gaps.len() < MAX_SCCM_CLIENT_INTAKE_ARTIFACTS { + 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( + "client intake capture-gap count exceeds the supported limit", + )); + } + + Ok(capture_gaps) + } + } + + deserializer.deserialize_seq(BoundedCaptureGapVisitor) +} + fn deserialize_bounded_client_artifacts<'de, D>( deserializer: D, ) -> Result, D::Error> @@ -327,6 +413,9 @@ pub struct SccmClientIntakeAssessment { /// 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, } @@ -509,6 +598,8 @@ struct SccmClientIntakeAssessmentWire { groups: Vec, physical_artifacts: Vec, unsupported_artifacts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + capture_gaps: Vec, coverage_gaps: Vec, } @@ -527,6 +618,7 @@ impl From<&SccmClientIntakeAssessment> for SccmClientIntakeAssessmentWire { .iter() .map(Into::into) .collect(), + capture_gaps: assessment.capture_gaps.clone(), coverage_gaps: assessment.coverage_gaps.iter().map(Into::into).collect(), } } @@ -561,6 +653,7 @@ impl<'de> Deserialize<'de> for SccmClientIntakeAssessment { .into_iter() .map(Into::into) .collect(), + capture_gaps: wire.capture_gaps, coverage_gaps: wire.coverage_gaps.into_iter().map(Into::into).collect(), }; @@ -611,6 +704,8 @@ pub enum SccmClientIntakeError { MissingFragmentCompleteness, #[error("client intake fragment completeness contradicts its declared coverage state")] InvalidFragmentCompleteness, + #[error("client intake capture gap is malformed, unsupported, or not coverage-only")] + InvalidCaptureGap, } #[derive(Clone, Copy)] @@ -713,6 +808,8 @@ pub fn assess_client_intake( 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 = @@ -749,12 +846,23 @@ pub fn assess_client_intake( } } + 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(); @@ -763,8 +871,12 @@ pub fn assess_client_intake( .remove(definition.logical_artifact_id) .unwrap_or_default(); fragments.sort_by(compare_fragments); - let coverage = group_coverage(&fragments); - if fragments.is_empty() { + 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, @@ -784,6 +896,15 @@ pub fn assess_client_intake( }); } } + 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(), @@ -797,6 +918,7 @@ pub fn assess_client_intake( groups, physical_artifacts, unsupported_artifacts, + capture_gaps, coverage_gaps, }) } @@ -840,8 +962,11 @@ fn validate_assessment_projection(assessment: &SccmClientIntakeAssessment) -> Re .map(unsupported_as_intake_artifact), ); - let canonical = assess_client_intake(&SccmClientIntakeBundle { artifacts }) - .map_err(|error| format!("invalid client intake assessment projection: {error}"))?; + 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(), @@ -896,7 +1021,12 @@ fn unsupported_as_intake_artifact( } fn validate_bundle(bundle: &SccmClientIntakeBundle) -> Result<(), SccmClientIntakeError> { - if bundle.artifacts.len() > MAX_SCCM_CLIENT_INTAKE_ARTIFACTS { + if bundle + .artifacts + .len() + .saturating_add(bundle.capture_gaps.len()) + > MAX_SCCM_CLIENT_INTAKE_ARTIFACTS + { return Err(SccmClientIntakeError::ArtifactLimitExceeded); } @@ -1086,6 +1216,65 @@ fn validate_bundle(bundle: &SccmClientIntakeBundle) -> Result<(), SccmClientInta } } + for capture_gap in &bundle.capture_gaps { + 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); + } + 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(()) } @@ -1168,10 +1357,18 @@ fn normalized_collected_at(value: Option<&str>) -> Option { }) } -fn group_coverage(fragments: &[SccmClientIntakeFragment]) -> SccmCoverageState { +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) } @@ -1246,6 +1443,20 @@ fn source_coverage_reason(fragment: &SccmClientIntakeFragment) -> Option } } +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. @@ -1286,6 +1497,18 @@ fn compare_fragments( .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)) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs index e1f791db0..845a28e30 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs @@ -5,9 +5,9 @@ use std::path::{Path, PathBuf}; use cmtraceopen_parser::sccm::{ assess_client_intake, classify_artifact_name, declared_client_source_groups, SccmArtifact, SccmClientIntakeArtifact, SccmClientIntakeAssessment, SccmClientIntakeBundle, - SccmClientIntakeCoverageGap, SccmClientIntakeError, SccmClientIntakeFragment, - SccmClientUnsupportedArtifact, SccmCoverageState, SccmRole, SccmRotation, SccmUnknownRotation, - MAX_SCCM_CLIENT_INTAKE_ARTIFACTS, + SccmClientIntakeCaptureGap, SccmClientIntakeCoverageGap, SccmClientIntakeError, + SccmClientIntakeFragment, SccmClientUnsupportedArtifact, SccmCoverageState, SccmRole, + SccmRotation, SccmUnknownRotation, MAX_SCCM_CLIENT_INTAKE_ARTIFACTS, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -227,6 +227,7 @@ fn load_bundle(scenario: &str) -> SccmClientIntakeBundle { } }) .collect(), + capture_gaps: Vec::new(), } } @@ -434,8 +435,11 @@ fn intake_rejects_more_than_the_v1_artifact_limit_before_validation_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 }) - .expect_err("the v1 client intake artifact ceiling must fail closed"); + 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(), @@ -481,6 +485,265 @@ fn intake_wire_rejects_more_than_the_v1_artifact_limit_from_json_text() { ); } +#[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 = serde_json::json!({ + "artifacts": [artifact], + "captureGaps": vec![capture_gap; MAX_SCCM_CLIENT_INTAKE_ARTIFACTS], + }); + + 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(|number| 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(number as u32), + 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), + }) + .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); +} + +#[test] +fn legacy_bundle_wire_omits_the_additive_empty_capture_gap_field() { + let artifact = serde_json::to_value(synthetic_artifact("legacy-wire", "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" + ); + } +} + +#[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 @@ -603,6 +866,7 @@ fn collection_timestamp_is_projected_as_canonical_utc() { let intake = assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }) .expect("a valid RFC 3339 collection instant remains representable"); assert_eq!( @@ -1085,6 +1349,7 @@ fn fragment_order_is_source_identity_then_rotation_rank() { 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 @@ -1196,6 +1461,7 @@ fn capped_cas_fragment_cannot_claim_complete() { 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" @@ -1210,6 +1476,7 @@ fn captured_incomplete_fragment_retains_a_boundary_without_becoming_capped() { 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"); @@ -1240,6 +1507,7 @@ fn parse_failed_fragment_completeness_is_intentionally_two_valued() { 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}") @@ -1279,6 +1547,7 @@ fn mixed_captured_and_absent_group_preserves_partial_coverage_and_names_the_abse 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"); @@ -1331,6 +1600,7 @@ fn mixed_captured_and_access_denied_group_preserves_partial_coverage_and_names_t 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"); @@ -1372,6 +1642,7 @@ fn mixed_capped_and_absent_group_keeps_the_capped_capture_and_names_the_absent_s 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"); @@ -1425,6 +1696,7 @@ fn duplicate_nonphysical_markers_for_the_same_source_fail_closed() { 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" @@ -1439,6 +1711,7 @@ fn duplicate_nonphysical_markers_for_the_same_source_fail_closed() { 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" @@ -1462,6 +1735,7 @@ fn absent_markers_with_distinct_path_fingerprints_remain_distinct_sources() { 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"); @@ -1499,6 +1773,7 @@ fn unpinned_marker_for_a_physically_declared_source_fails_closed() { 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" @@ -1506,6 +1781,7 @@ fn unpinned_marker_for_a_physically_declared_source_fails_closed() { 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" @@ -1528,6 +1804,7 @@ fn pinned_markers_for_distinct_roots_coexist_with_physical_evidence() { 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); @@ -1541,6 +1818,7 @@ fn pinned_markers_for_distinct_roots_coexist_with_physical_evidence() { assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![pinned, captured], + capture_gaps: Vec::new(), }) .expect("declaration order must not collapse distinct roots"); } @@ -1552,6 +1830,7 @@ fn pinned_markers_for_distinct_roots_coexist_with_physical_evidence() { 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!( @@ -1572,6 +1851,7 @@ fn a_marker_cannot_reuse_the_physical_source_fingerprint() { assert_eq!( assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![captured, marker], + capture_gaps: Vec::new(), }), Err(SccmClientIntakeError::CollidingPhysicalIdentity) ); @@ -1596,6 +1876,7 @@ fn unpinned_and_pinned_markers_for_the_same_source_fail_closed() { 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" @@ -1603,6 +1884,7 @@ fn unpinned_and_pinned_markers_for_the_same_source_fail_closed() { 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" @@ -1625,6 +1907,7 @@ fn markers_for_distinct_rotations_of_a_captured_source_remain_representable() { 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"); @@ -1681,6 +1964,7 @@ fn unknown_and_lookalike_names_are_retained_as_unsupported_not_reclassified() { 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"); @@ -1707,6 +1991,7 @@ fn malformed_rotation_and_public_provenance_values_fail_closed() { 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" @@ -1719,6 +2004,7 @@ fn malformed_rotation_and_public_provenance_values_fail_closed() { 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" @@ -1730,6 +2016,7 @@ fn malformed_rotation_and_public_provenance_values_fail_closed() { 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" @@ -1741,6 +2028,7 @@ fn malformed_rotation_and_public_provenance_values_fail_closed() { 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" @@ -1755,6 +2043,7 @@ fn configmgr_version_and_encoding_use_bounded_public_grammars() { assert!( assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }) .is_ok(), "documented ConfigMgr version {version} should remain representable" @@ -1773,6 +2062,7 @@ fn configmgr_version_and_encoding_use_bounded_public_grammars() { assert_eq!( assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }), Err(SccmClientIntakeError::InvalidConfigMgrVersion), "unsafe ConfigMgr version {version:?} must fail closed" @@ -1785,6 +2075,7 @@ fn configmgr_version_and_encoding_use_bounded_public_grammars() { assert!( assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }) .is_ok(), "supported encoding {encoding} should remain representable" @@ -1803,6 +2094,7 @@ fn configmgr_version_and_encoding_use_bounded_public_grammars() { assert_eq!( assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }), Err(SccmClientIntakeError::InvalidEncoding), "unsafe encoding {encoding:?} must fail closed" @@ -1830,6 +2122,7 @@ fn unknown_rotation_public_metadata_is_versioned_and_opaque() { assert_eq!( assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }), Err(SccmClientIntakeError::InvalidRotation) ); @@ -1854,6 +2147,7 @@ fn unknown_rotation_public_metadata_is_versioned_and_opaque() { assert_eq!( assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }), Err(SccmClientIntakeError::InvalidRotation) ); @@ -1867,6 +2161,7 @@ fn unknown_rotation_public_metadata_is_versioned_and_opaque() { 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); @@ -1893,6 +2188,7 @@ fn distinct_opaque_unknown_rotations_do_not_collapse_to_one_source_identity() { let intake = assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![captured, unavailable], + capture_gaps: Vec::new(), }) .expect("distinct opaque rotations remain distinct declarations"); @@ -1917,6 +2213,7 @@ fn caller_controlled_public_identity_channels_fail_closed() { 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" @@ -1933,6 +2230,7 @@ fn caller_controlled_public_identity_channels_fail_closed() { assert_eq!( assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }), Err(SccmClientIntakeError::InvalidBasename), "identity-bearing unsupported basename {basename:?} reached public output" @@ -1950,6 +2248,7 @@ fn caller_controlled_public_identity_channels_fail_closed() { 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:?}" @@ -1962,6 +2261,7 @@ fn caller_controlled_public_identity_channels_fail_closed() { 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" @@ -1974,6 +2274,7 @@ fn public_identity_contract_retains_only_reviewed_synthetic_and_opaque_forms() { 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()); @@ -1982,6 +2283,7 @@ fn public_identity_contract_retains_only_reviewed_synthetic_and_opaque_forms() { 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); @@ -1992,6 +2294,7 @@ fn public_identity_contract_retains_only_reviewed_synthetic_and_opaque_forms() { 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"), ) @@ -2009,6 +2312,7 @@ fn public_identity_contract_retains_only_reviewed_synthetic_and_opaque_forms() { assert_eq!( assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![oversized_timestamp], + capture_gaps: Vec::new(), }), Err(SccmClientIntakeError::InvalidCollectedAt) ); @@ -2023,6 +2327,7 @@ fn fragment_completeness_and_every_path_fingerprint_are_explicit_and_unambiguous assert_eq!( assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![missing_completeness], + capture_gaps: Vec::new(), }), Err(SccmClientIntakeError::MissingFragmentCompleteness), "fragment completeness must be an explicit declaration" @@ -2037,6 +2342,7 @@ fn fragment_completeness_and_every_path_fingerprint_are_explicit_and_unambiguous 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" @@ -2049,6 +2355,7 @@ fn fragment_completeness_and_every_path_fingerprint_are_explicit_and_unambiguous 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" @@ -2066,6 +2373,7 @@ fn fragment_completeness_and_every_path_fingerprint_are_explicit_and_unambiguous 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" @@ -2079,6 +2387,7 @@ fn unsupported_physical_artifacts_retain_safe_provenance_without_raw_host_or_pat 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"); @@ -2236,6 +2545,7 @@ fn identity_bearing_relative_paths_fail_before_public_projection() { let result = assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }); assert!( matches!(result, Err(SccmClientIntakeError::InvalidRelativePath)), @@ -2254,6 +2564,7 @@ fn identity_bearing_relative_paths_fail_before_public_projection() { 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" @@ -2267,6 +2578,7 @@ fn rotation_lineage_is_versioned_privacy_safe_and_bound_to_one_source() { 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"); @@ -2283,6 +2595,7 @@ fn rotation_lineage_is_versioned_privacy_safe_and_bound_to_one_source() { assert_eq!( assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }), Err(SccmClientIntakeError::InvalidRotationLineage), "unsafe lineage reached the public projection: {lineage:?}" @@ -2296,6 +2609,7 @@ fn rotation_lineage_is_versioned_privacy_safe_and_bound_to_one_source() { 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" @@ -2329,6 +2643,7 @@ fn shared_location_services_path_binding_preserves_every_canonical_rotation() { let assessment = assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }) .unwrap_or_else(|error| { panic!( @@ -2389,6 +2704,7 @@ fn unsafe_path_fingerprints_fail_before_public_projection() { let result = assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }); assert!( matches!(result, Err(SccmClientIntakeError::InvalidPathFingerprint)), @@ -2412,6 +2728,7 @@ fn sha256_path_fingerprints_require_exactly_64_lowercase_hex_characters() { assert_eq!( assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }), Err(SccmClientIntakeError::InvalidPathFingerprint), "invalid SHA-256 digest was accepted: {digest:?}" @@ -2422,6 +2739,7 @@ fn sha256_path_fingerprints_require_exactly_64_lowercase_hex_characters() { artifact.path_fingerprint = Some(format!("sha256:{}", "a".repeat(64))); assert!(assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }) .is_ok()); } @@ -2435,6 +2753,7 @@ fn numbered_synthetic_path_fingerprints_accept_only_a_short_numeric_suffix() { 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()); @@ -2443,6 +2762,7 @@ fn numbered_synthetic_path_fingerprints_accept_only_a_short_numeric_suffix() { assert_eq!( assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![oversized], + capture_gaps: Vec::new(), }), Err(SccmClientIntakeError::InvalidPathFingerprint) ); @@ -2465,6 +2785,7 @@ fn approved_namespaced_path_fingerprints_remain_accepted() { assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }) .unwrap_or_else(|error| panic!("approved fingerprint {fingerprint:?} failed: {error}")); } @@ -2528,6 +2849,7 @@ fn approved_collision_safe_relative_layouts_remain_accepted() { assess_client_intake(&SccmClientIntakeBundle { artifacts: vec![artifact], + capture_gaps: Vec::new(), }) .unwrap_or_else(|error| panic!("approved path {relative_path:?} failed: {error}")); } From 8dce4f60e4eb4f338c7a16bc9e3cca93a3d53e98 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 04:34:00 -0400 Subject: [PATCH 279/422] fix(sccm): validate client capture gap boundaries (#319) --- .../src/sccm/client/intake.rs | 372 ++++++++++++------ .../tests/sccm_client_intake.rs | 173 +++++++- 2 files changed, 406 insertions(+), 139 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/intake.rs b/crates/cmtraceopen-parser/src/sccm/client/intake.rs index 6aa2f520f..f39f64d6f 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -4,8 +4,8 @@ use std::fmt; use chrono::{DateTime, SecondsFormat, Utc}; use serde::{ - de::{Error as _, IgnoredAny, SeqAccess, Visitor}, - ser::Error as _, + de::{DeserializeSeed, Error as _, IgnoredAny, MapAccess, SeqAccess, Visitor}, + ser::{Error as _, SerializeStruct}, Deserialize, Deserializer, Serialize, Serializer, }; use thiserror::Error; @@ -192,8 +192,7 @@ pub struct SccmClientIntakeArtifact { /// 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, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[derive(Debug, Clone, PartialEq)] pub struct SccmClientIntakeCaptureGap { pub artifact_id: String, pub basename: String, @@ -203,23 +202,87 @@ pub struct SccmClientIntakeCaptureGap { pub rotation_lineage: String, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[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. - #[serde(default, skip_serializing_if = "Vec::is_empty")] pub capture_gaps: Vec, } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SccmClientIntakeBundleWire { - #[serde(deserialize_with = "deserialize_bounded_client_artifacts")] - artifacts: Vec, - #[serde(default, deserialize_with = "deserialize_bounded_client_capture_gaps")] - 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 { @@ -227,131 +290,176 @@ impl<'de> Deserialize<'de> for SccmClientIntakeBundle { where D: Deserializer<'de>, { - let wire = SccmClientIntakeBundleWire::deserialize(deserializer)?; - if wire.artifacts.len().saturating_add(wire.capture_gaps.len()) - > MAX_SCCM_CLIENT_INTAKE_ARTIFACTS - { - return Err(D::Error::custom( - "client intake artifact count exceeds the supported limit", - )); + 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); + } + } } - Ok(Self { - artifacts: wire.artifacts, - capture_gaps: wire.capture_gaps, + + Ok(SccmClientIntakeBundle { + artifacts: artifacts.ok_or_else(|| A::Error::missing_field("artifacts"))?, + capture_gaps: capture_gaps.unwrap_or_default(), }) } } -fn deserialize_bounded_client_capture_gaps<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - struct BoundedCaptureGapVisitor; +struct BoundedCaptureGapsSeed { + limit: usize, +} - impl<'de> Visitor<'de> for BoundedCaptureGapVisitor { - type Value = Vec; +impl<'de> DeserializeSeed<'de> for BoundedCaptureGapsSeed { + type Value = Vec; - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - formatter, - "at most {MAX_SCCM_CLIENT_INTAKE_ARTIFACTS} SCCM client capture gaps" - ) - } + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_seq(BoundedCaptureGapVisitor { limit: self.limit }) + } +} - fn visit_seq(self, mut sequence: A) -> Result - where - A: SeqAccess<'de>, - { - if sequence - .size_hint() - .is_some_and(|size| size > MAX_SCCM_CLIENT_INTAKE_ARTIFACTS) - { - return Err(A::Error::custom( - "client intake capture-gap count exceeds the supported limit", - )); - } +struct BoundedCaptureGapVisitor { + limit: usize, +} - let initial_capacity = sequence - .size_hint() - .unwrap_or_default() - .min(MAX_SCCM_CLIENT_INTAKE_ARTIFACTS); - let mut capture_gaps = Vec::with_capacity(initial_capacity); - while capture_gaps.len() < MAX_SCCM_CLIENT_INTAKE_ARTIFACTS { - let Some(capture_gap) = sequence.next_element()? else { - return Ok(capture_gaps); - }; - capture_gaps.push(capture_gap); - } +impl<'de> Visitor<'de> for BoundedCaptureGapVisitor { + type Value = Vec; - if sequence.next_element::()?.is_some() { - return Err(A::Error::custom( - "client intake capture-gap count exceeds the supported limit", - )); - } + 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); + } - Ok(capture_gaps) + if sequence.next_element::()?.is_some() { + return Err(A::Error::custom( + SccmClientIntakeError::ArtifactLimitExceeded, + )); } + + Ok(capture_gaps) } +} - deserializer.deserialize_seq(BoundedCaptureGapVisitor) +struct BoundedArtifactsSeed { + limit: usize, } -fn deserialize_bounded_client_artifacts<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - struct BoundedArtifactVisitor; +impl<'de> DeserializeSeed<'de> for BoundedArtifactsSeed { + type Value = Vec; - impl<'de> Visitor<'de> for BoundedArtifactVisitor { - type Value = Vec; + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_seq(BoundedArtifactVisitor { limit: self.limit }) + } +} - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - formatter, - "at most {MAX_SCCM_CLIENT_INTAKE_ARTIFACTS} SCCM client artifacts" - ) - } +struct BoundedArtifactVisitor { + limit: usize, +} - fn visit_seq(self, mut sequence: A) -> Result - where - A: SeqAccess<'de>, - { - if sequence - .size_hint() - .is_some_and(|size| size > MAX_SCCM_CLIENT_INTAKE_ARTIFACTS) - { - return Err(A::Error::custom( - SccmClientIntakeError::ArtifactLimitExceeded, - )); - } +impl<'de> Visitor<'de> for BoundedArtifactVisitor { + type Value = Vec; - let initial_capacity = sequence - .size_hint() - .unwrap_or_default() - .min(MAX_SCCM_CLIENT_INTAKE_ARTIFACTS); - let mut artifacts = Vec::with_capacity(initial_capacity); - while artifacts.len() < MAX_SCCM_CLIENT_INTAKE_ARTIFACTS { - let Some(artifact) = sequence.next_element()? else { - return Ok(artifacts); - }; - artifacts.push(artifact); - } + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "at most {} SCCM client artifacts", self.limit) + } - if sequence.next_element::()?.is_some() { - return Err(A::Error::custom( - SccmClientIntakeError::ArtifactLimitExceeded, - )); - } + 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, + )); + } - Ok(artifacts) + 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, + )); } - } - deserializer.deserialize_seq(BoundedArtifactVisitor) + Ok(artifacts) + } } #[derive(Debug, Clone, PartialEq)] @@ -1020,6 +1128,27 @@ fn unsupported_as_intake_artifact( } } +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 @@ -1217,20 +1346,7 @@ fn validate_bundle(bundle: &SccmClientIntakeBundle) -> Result<(), SccmClientInta } for capture_gap in &bundle.capture_gaps { - 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); - } + validate_capture_gap_shape(capture_gap)?; if !artifact_ids.insert(capture_gap.artifact_id.to_ascii_lowercase()) { return Err(SccmClientIntakeError::DuplicateArtifactId); } diff --git a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs index 845a28e30..c7b6883bd 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs @@ -545,17 +545,25 @@ fn intake_wire_rejects_a_combined_artifact_and_capture_gap_count_above_the_v1_li rotation_lineage: "synthetic:capped-rotation".to_owned(), }) .expect("synthetic capture gap serializes"); - let oversized = serde_json::json!({ - "artifacts": [artifact], - "captureGaps": vec![capture_gap; MAX_SCCM_CLIENT_INTAKE_ARTIFACTS], - }); + 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], + }), + ]; - 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}" - ); + 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] @@ -607,7 +615,7 @@ fn intake_accepts_the_shared_v1_boundary_across_physical_and_coverage_only_decla #[test] fn legacy_bundle_wire_omits_the_additive_empty_capture_gap_field() { - let artifact = serde_json::to_value(synthetic_artifact("legacy-wire", "PolicyAgent.log")) + 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] }); @@ -671,6 +679,149 @@ fn capture_gap_wire_rejects_physical_path_or_byte_claims() { } } +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" + ); +} + +#[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"); From fd52c4ceb6bde211f26d3647eda010e8fe81d8c0 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 04:46:48 -0400 Subject: [PATCH 280/422] fix(sccm): validate client intake decoding (#319) --- .../src/sccm/client/intake.rs | 29 +++- .../tests/sccm_client_intake.rs | 159 +++++++++++++++--- 2 files changed, 162 insertions(+), 26 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/intake.rs b/crates/cmtraceopen-parser/src/sccm/client/intake.rs index f39f64d6f..fbe0135d1 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -345,10 +345,12 @@ impl<'de> Visitor<'de> for SccmClientIntakeBundleVisitor { } } - Ok(SccmClientIntakeBundle { + 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) } } @@ -706,11 +708,32 @@ struct SccmClientIntakeAssessmentWire { groups: Vec, physical_artifacts: Vec, unsupported_artifacts: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[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 { diff --git a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs index c7b6883bd..c77263fa5 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs @@ -428,6 +428,30 @@ fn synthetic_marker( 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), + } +} + #[test] fn intake_rejects_more_than_the_v1_artifact_limit_before_validation_indexes() { // Keep the input otherwise invalid (all declarations collide) so this @@ -449,15 +473,18 @@ fn intake_rejects_more_than_the_v1_artifact_limit_before_validation_indexes() { #[test] fn intake_wire_rejects_more_than_the_v1_artifact_limit_while_deserializing() { - let artifact = serde_json::to_value(synthetic_artifact("wire-limit", "PolicyAgent.log")) - .expect("synthetic intake artifact serializes"); + let boundary_artifacts = (1..=MAX_SCCM_CLIENT_INTAKE_ARTIFACTS) + .map(opaque_numbered_artifact) + .collect::>(); let boundary = serde_json::json!({ - "artifacts": vec![artifact.clone(); MAX_SCCM_CLIENT_INTAKE_ARTIFACTS], + "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], }); @@ -569,26 +596,7 @@ fn intake_wire_rejects_a_combined_artifact_and_capture_gap_count_above_the_v1_li #[test] fn intake_accepts_the_shared_v1_boundary_across_physical_and_coverage_only_declarations() { let artifacts = (1..MAX_SCCM_CLIENT_INTAKE_ARTIFACTS) - .map(|number| 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(number as u32), - 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), - }) + .map(opaque_numbered_artifact) .collect(); let capture_gap = SccmClientIntakeCaptureGap { artifact_id: format!("sccm-artifact:v1:sha256:{MAX_SCCM_CLIENT_INTAKE_ARTIFACTS:064x}"), @@ -611,6 +619,12 @@ fn intake_accepts_the_shared_v1_boundary_across_physical_and_coverage_only_decla 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] @@ -788,6 +802,75 @@ fn bundle_serialization_rejects_colliding_standalone_valid_capture_gaps() { ); } +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 = @@ -979,6 +1062,36 @@ fn public_assessment_deserialization_rejects_forged_coverage_and_identity() { ); } +#[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"); From c3a73024fecc23fe5f11c1e2c60785b8c1f3e80c Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 04:50:46 -0400 Subject: [PATCH 281/422] fix(sccm): close site core evidence contracts --- .../src/sccm/server/windows/site_core.rs | 688 ++++++++++++++---- .../server-sitecomp}/current/sitecomp.log | 0 .../site_core/component-failure/expected.json | 178 ++++- .../site_core/component-failure/manifest.json | 56 +- .../server-sitecomp}/current/sitecomp.log | 0 .../server-status}/current/statmgr.log | 0 .../site_core/contradictory/expected.json | 166 +++-- .../site_core/contradictory/manifest.json | 59 +- .../server-sitecomp}/current/sitecomp.log | 0 .../server-status}/current/statmgr.log | 0 .../server/site_core/healthy/expected.json | 62 +- .../server/site_core/healthy/manifest.json | 59 +- .../server-sitecomp}/current/sitecomp.log | 0 .../site_core/inbox-backlog/expected.json | 199 +++-- .../site_core/inbox-backlog/manifest.json | 56 +- .../server-sitecomp}/current/sitecomp.log | 0 .../server/site_core/incomplete/expected.json | 274 +++++-- .../server/site_core/incomplete/manifest.json | 84 +-- .../server-status}/current/statmgr.log | 0 .../server/site_core/malformed/expected.json | 131 ++-- .../server/site_core/malformed/manifest.json | 38 +- .../server-sitecomp}/current/sitecomp.log | 0 .../server-status}/current/statmgr.log | 0 .../server/site_core/recovery/expected.json | 109 ++- .../server/site_core/recovery/manifest.json | 59 +- .../server-sitecomp}/current/sitecomp.log | 0 .../server-sitecomp}/lo_/sitecomp.lo_ | 0 .../site_core/rotation-boundary/expected.json | 211 ++++-- .../site_core/rotation-boundary/manifest.json | 63 +- .../server-sitecomp}/current/sitecomp.log | 0 .../server-status}/current/statmgr.log | 0 .../status-processing-failure/expected.json | 119 ++- .../status-processing-failure/manifest.json | 59 +- .../tests/sccm_server_site_core.rs | 79 +- .../tests/sccm_site_core_fixture_contract.rs | 39 +- 35 files changed, 1858 insertions(+), 930 deletions(-) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/{site-core/sitecomp => site-server/server-sitecomp}/current/sitecomp.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/{site-core/sitecomp => site-server/server-sitecomp}/current/sitecomp.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/{site-core/status => site-server/server-status}/current/statmgr.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/{site-core/sitecomp => site-server/server-sitecomp}/current/sitecomp.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/{site-core/status => site-server/server-status}/current/statmgr.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/{site-core/sitecomp => site-server/server-sitecomp}/current/sitecomp.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/evidence/sccm/server/{site-core/sitecomp => site-server/server-sitecomp}/current/sitecomp.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/evidence/sccm/server/{site-core/status => site-server/server-status}/current/statmgr.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/{site-core/sitecomp => site-server/server-sitecomp}/current/sitecomp.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/{site-core/status => site-server/server-status}/current/statmgr.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/{site-core/sitecomp => site-server/server-sitecomp}/current/sitecomp.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/{site-core/sitecomp => site-server/server-sitecomp}/lo_/sitecomp.lo_ (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/{site-core/sitecomp => site-server/server-sitecomp}/current/sitecomp.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/{site-core/status => site-server/server-status}/current/statmgr.log (100%) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs index 95eaa5915..7ad171f67 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs @@ -8,6 +8,7 @@ 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::{ @@ -30,6 +31,7 @@ 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 STATE_CHAIN: [SccmSiteCorePhase; 5] = [ SccmSiteCorePhase::ComponentStart, @@ -161,14 +163,20 @@ pub struct SccmSiteCoreRequestScope { 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 basenames: Vec, - pub rotations: Vec, + pub candidates: Vec, pub max_artifacts: usize, #[serde(skip_serializing_if = "Option::is_none")] pub max_bytes_per_artifact: Option, @@ -208,6 +216,7 @@ pub struct SccmSiteCoreObservation { pub state: SccmSiteCoreState, pub finding_class: SccmFindingClass, pub confidence: SccmSiteCoreConfidence, + pub evidence: Vec, pub coverage_gap_artifact_ids: Vec, pub next_artifacts: Vec, } @@ -218,6 +227,7 @@ pub struct SccmSiteCoreCoverageGap { pub artifact_id: String, pub source_id: String, pub state: SccmCoverageState, + pub reason_code: String, pub diagnostic_meaning: SccmSiteCoreDiagnosticMeaning, } @@ -266,13 +276,6 @@ impl SiteCoreGroup { } } - fn expected_component(self) -> &'static str { - match self { - Self::Component => "SMS_SITE_COMPONENT_MANAGER", - Self::Status => "SMS_STATUS_MANAGER", - } - } - fn from_source_id(value: &str) -> Option { match value { SCCM_SITE_CORE_COMPONENT_GROUP => Some(Self::Component), @@ -287,6 +290,7 @@ struct AdmittedSource<'a> { artifact: &'a SccmServerArtifactAssessment, group: SiteCoreGroup, fact_eligible: bool, + rejection_reason: Option<&'static str>, } struct SiteCoreContext<'a> { @@ -297,11 +301,15 @@ struct SiteCoreContext<'a> { impl<'a> SiteCoreContext<'a> { fn new(intake: &'a SccmServerIntakeAssessment) -> Self { - let sources = admitted_sources(intake); + 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 coverage_congruent = site_core_coverage_is_congruent(intake); + let sources = admitted_sources(intake, &collision_artifact_ids, coverage_congruent); let coverage_gaps = collect_coverage_gaps(intake, &sources); Self { sources, - evidence_identity_is_unique: unique_evidence_identities(&intake.evidence), + evidence_identity_is_unique, coverage_gaps, } } @@ -310,6 +318,7 @@ impl<'a> SiteCoreContext<'a> { pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAnalysis { let context = SiteCoreContext::new(intake); let mut grouped = BTreeMap::>::new(); + let mut record_observations = Vec::new(); for (position, evidence) in intake.evidence.iter().enumerate() { let Some(source) = context.sources.get(evidence.reference.artifact_id.as_str()) else { continue; @@ -321,8 +330,18 @@ pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAna { continue; } - if let Some(fact) = parse_fact(evidence, source, &intake.topology.site_handle) { - grouped.entry(fact.key.clone()).or_default().push(fact); + 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 => {} } } @@ -331,12 +350,10 @@ pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAna for (key, mut facts) in grouped { facts.sort_by(compare_facts); let gap_ids = coverage_gap_ids_for_key(&context, &key); - let mut reduced = reduce_transaction(key, &facts, &context, &gap_ids); + 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); - } else { - reduced.finding_class = None; } } results.push(reduced); @@ -350,7 +367,9 @@ pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAna }); 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); @@ -395,6 +414,8 @@ pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAna fn admitted_sources<'a>( intake: &'a SccmServerIntakeAssessment, + collision_artifact_ids: &BTreeSet, + coverage_congruent: bool, ) -> BTreeMap<&'a str, AdmittedSource<'a>> { let mut occurrences = BTreeMap::<&str, usize>::new(); for artifact in &intake.artifacts { @@ -408,38 +429,68 @@ fn admitted_sources<'a>( .iter() .filter_map(|artifact| { let group = SiteCoreGroup::from_source_id(&artifact.source_id)?; - if artifact.producer_role != SccmRole::SiteServer - || artifact.workflow_subject_role.is_some() - || occurrences.get(artifact.artifact_id.as_str()) != Some(&1) - { + 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 !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: shape_valid && source_carries_facts(artifact), + fact_eligible: rejection_reason.is_none(), + rejection_reason, }, )) }) .collect() } +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 => classified.logical_name == "sitecomp", - SiteCoreGroup::Status => classified.logical_name == "statmgr", + 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.is_some() + && artifact.rotation.as_ref() == Some(&classified.rotation) && classified.supported_for_diagnosis && classified.family == group.family() && classified.role == SccmRole::SiteServer @@ -456,6 +507,18 @@ fn source_shape_is_valid(artifact: &SccmServerArtifactAssessment, group: SiteCor }) } +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 @@ -507,26 +570,60 @@ 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 source in sources.values() { - if source.fact_eligible || absent_default_is_superseded(source.artifact, sources) { + 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 state = if source.artifact.state == SccmCoverageState::Captured { - if source.artifact.fragment_complete == Some(false) - || source.artifact.truncated == Some(true) - { + 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" + | "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 { - source.artifact.state.clone() + artifact.state.clone() }; gaps.push(SccmSiteCoreCoverageGap { - artifact_id: source.artifact.artifact_id.clone(), - source_id: source.artifact.source_id.clone(), + artifact_id: artifact.artifact_id.clone(), + source_id: artifact.source_id.clone(), state, + reason_code: reason_code.to_owned(), diagnostic_meaning: SccmSiteCoreDiagnosticMeaning::CoverageOnly, }); } @@ -535,21 +632,14 @@ fn collect_coverage_gaps( .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 }); - - // An assessment may be externally reordered, but it must not manufacture a - // gap for an artifact that does not exist in the assessment. - let artifact_ids = intake - .artifacts - .iter() - .map(|artifact| artifact.artifact_id.as_str()) - .collect::>(); - gaps.retain(|gap| artifact_ids.contains(gap.artifact_id.as_str())); gaps } @@ -698,46 +788,89 @@ impl SiteCoreFact { } } +enum ProfileRecordParse { + NotCandidate, + Rejected(&'static str), + Accepted(Box), +} + fn parse_fact( evidence: &SccmEvidence, source: &AdmittedSource<'_>, site_handle: &str, -) -> Option { - if evidence.component.as_deref() != Some(source.group.expected_component()) { - return None; - } +) -> ProfileRecordParse { let message = evidence.message.as_str(); - if token_value(message, "profileId")? != SCCM_SITE_CORE_PROFILE_ID - || token_value(message, "profileVersion")? != SCCM_SITE_CORE_PROFILE_VERSION.to_string() + 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" - || token_value(message, "site")? != "LAB" + || site != "LAB" { - return None; + return ProfileRecordParse::Rejected("profile-identity-rejected"); } - let component_id = validated_identifier(&token_value(message, "componentId")?)?; - let work_item_id = validated_identifier(&token_value(message, "workItemId")?)?; - let marker = status_marker(&token_value(message, "statusId")?)?; + 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 - || token_value(message, "outcome")? != marker.outcome.token() - || token_value(message, "terminal")? != if marker.terminal { "true" } else { "false" } + || outcome != marker.outcome.token() + || terminal != if marker.terminal { "true" } else { "false" } + || !queue_depth_matches_marker(message, marker) { - return None; + return ProfileRecordParse::Rejected("profile-status-schema-rejected"); } - Some(SiteCoreFact { + 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()?, + 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( @@ -769,9 +902,7 @@ fn reduce_transaction( .is_some_and(|(failure, recovery)| failure < recovery) }) }); - let has_deferred = facts - .iter() - .any(|fact| fact.marker.outcome == FactOutcome::Deferred); + let has_deferred = has_unrecovered_deferred(facts); let has_component_progress = successes.iter().any(|fact| { matches!( fact.marker.phase, @@ -861,9 +992,14 @@ fn reduce_transaction( 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 = format!( - "site-core:{}:{}:{}:{}", - key.site_handle, key.producer_host_handle, key.component_id, key.work_item_id + 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 { @@ -880,6 +1016,24 @@ fn reduce_transaction( } } +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; @@ -959,7 +1113,9 @@ fn next_artifacts_for_state( .iter() .any(|fact| fact.reference.artifact_id == source.artifact.artifact_id) }) { - return vec![recapture_request(source.artifact, Some(key))]; + return recapture_request(source.artifact, Some(key)) + .into_iter() + .collect(); } if facts .iter() @@ -984,9 +1140,17 @@ fn status_request( logical_name: SCCM_SITE_CORE_STATUS_GROUP.to_owned(), role: SccmRole::SiteServer, reason_code: reason_code.to_owned(), - basenames: vec!["statmgr.log".to_owned(), "statmgr.lo_".to_owned()], - rotations: vec!["current".to_owned(), "loUnderscore".to_owned()], - max_artifacts: 2, + candidates: vec![ + SccmSiteCoreArtifactCandidate { + basename: "statmgr.log".to_owned(), + rotation: "current".to_owned(), + }, + SccmSiteCoreArtifactCandidate { + basename: "statmgr.lo_".to_owned(), + rotation: "loUnderscore".to_owned(), + }, + ], + max_artifacts: MAX_SITE_CORE_REQUEST_ARTIFACTS, max_bytes_per_artifact: None, scope: SccmSiteCoreRequestScope { producer_host_handle: key.map(|key| key.producer_host_handle.clone()), @@ -1000,7 +1164,8 @@ fn status_request( fn recapture_request( artifact: &SccmServerArtifactAssessment, key: Option<&SccmSiteCoreTransactionKey>, -) -> SccmSiteCoreArtifactRequest { +) -> Option { + let candidate = request_candidate(artifact)?; let current_limit = artifact .capture_provenance .as_ref() @@ -1008,17 +1173,11 @@ fn recapture_request( .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); - SccmSiteCoreArtifactRequest { + Some(SccmSiteCoreArtifactRequest { logical_name: artifact.source_id.clone(), role: SccmRole::SiteServer, reason_code: "capped-before-next-phase".to_owned(), - basenames: artifact.original_basename.clone().into_iter().collect(), - rotations: artifact - .rotation - .as_ref() - .and_then(rotation_name) - .into_iter() - .collect(), + candidates: vec![candidate], max_artifacts: 1, max_bytes_per_artifact: Some(bounded), scope: SccmSiteCoreRequestScope { @@ -1029,7 +1188,7 @@ fn recapture_request( work_item_id: key.map(|key| key.work_item_id.clone()), rotation_lineage_handle: Some(artifact.rotation_lineage_handle.clone()), }, - } + }) } fn coverage_observations( @@ -1037,27 +1196,22 @@ fn coverage_observations( context: &SiteCoreContext<'_>, ) -> Vec { gaps.iter() - .filter(|gap| { - matches!( - gap.state, - SccmCoverageState::Capped | SccmCoverageState::ParseFailed - ) - }) .map(|gap| { - let request = context - .sources - .get(gap.artifact_id.as_str()) - .map(|source| match gap.state { - SccmCoverageState::Capped => recapture_request(source.artifact, None), - _ => complete_source_request(source.artifact), - }) - .into_iter() - .collect(); + let request = coverage_request(gap, context).into_iter().collect(); SccmSiteCoreObservation { - observation_id: format!("site-core:coverage:{}", gap.artifact_id), + 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, } @@ -1065,18 +1219,73 @@ fn coverage_observations( .collect() } -fn complete_source_request(artifact: &SccmServerArtifactAssessment) -> SccmSiteCoreArtifactRequest { - SccmSiteCoreArtifactRequest { +fn rejected_record_observation( + evidence: &SccmEvidence, + source: &AdmittedSource<'_>, + reason_code: &str, +) -> SccmSiteCoreObservation { + let line_start = evidence.reference.line_start.unwrap_or_default(); + let line_end = evidence.reference.line_end.unwrap_or_default(); + let request = complete_source_request(source.artifact, reason_code).unwrap_or_else(|| { + group_request( + source.group, + reason_code, + source.artifact.producer_host_handle.clone(), + ) + }); + 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: vec![SccmSiteCoreEvidence { + artifact_id: evidence.reference.artifact_id.clone(), + entry_id: evidence.reference.entry_id.clone(), + line_start, + line_end, + terminal: None, + recovery: None, + complete_logical_record: Some(true), + }], + coverage_gap_artifact_ids: Vec::new(), + next_artifacts: vec![request], + } +} + +fn coverage_request( + gap: &SccmSiteCoreCoverageGap, + context: &SiteCoreContext<'_>, +) -> Option { + 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); + } + } + SiteCoreGroup::from_source_id(&gap.source_id) + .map(|group| group_request(group, &gap.reason_code, None)) +} + +fn complete_source_request( + artifact: &SccmServerArtifactAssessment, + reason_code: &str, +) -> Option { + let candidate = request_candidate(artifact)?; + Some(SccmSiteCoreArtifactRequest { logical_name: artifact.source_id.clone(), role: SccmRole::SiteServer, - reason_code: "complete-logical-record-required".to_owned(), - basenames: artifact.original_basename.clone().into_iter().collect(), - rotations: artifact - .rotation - .as_ref() - .and_then(rotation_name) - .into_iter() - .collect(), + reason_code: reason_code.to_owned(), + candidates: vec![candidate], max_artifacts: 1, max_bytes_per_artifact: None, scope: SccmSiteCoreRequestScope { @@ -1085,9 +1294,59 @@ fn complete_source_request(artifact: &SccmServerArtifactAssessment) -> SccmSiteC work_item_id: None, rotation_lineage_handle: Some(artifact.rotation_lineage_handle.clone()), }, + }) +} + +fn group_request( + group: SiteCoreGroup, + reason_code: &str, + producer_host_handle: Option, +) -> 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: SccmSiteCoreRequestScope { + producer_host_handle, + component_id: None, + work_item_id: None, + rotation_lineage_handle: None, + }, } } +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, @@ -1108,7 +1367,10 @@ fn build_result_finding( } else { Vec::new() }; - let finding = SccmFindingBuilder::new(format!("finding:{}", result.result_id)) + let finding = SccmFindingBuilder::new(stable_opaque_id( + "site-core:finding:v1:", + &[&result.result_id], + )) .class(class) .phase(SccmPhase::Unknown( result @@ -1158,21 +1420,45 @@ fn build_observation_finding( observation: &SccmSiteCoreObservation, context: &SiteCoreContext<'_>, ) -> Option { - let finding = SccmFindingBuilder::new(format!("finding:{}", observation.observation_id)) - .class(observation.finding_class.clone()) - .phase(SccmPhase::Unknown("siteCoreCoverage".to_owned())) - .role(SccmRole::SiteServer) - .severity(Severity::Warning) - .confidence(shared_confidence(observation.confidence)) - .title("Site core coverage gap") - .summary("The source is incomplete and cannot establish a component outcome.") - .coverage_gaps(finding_gaps( - &observation.coverage_gap_artifact_ids, - context, - )) - .next_artifacts(shared_requests(&observation.next_artifacts)) - .build() - .ok()?; + 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(), @@ -1199,9 +1485,9 @@ fn finding_gaps( fn shared_requests(requests: &[SccmSiteCoreArtifactRequest]) -> Vec { let mut shared = requests .iter() - .flat_map(|request| request.basenames.iter()) - .filter_map(|basename| { - let classified = classify_artifact_name(basename, SccmRole::SiteServer); + .flat_map(|request| request.candidates.iter()) + .filter_map(|candidate| { + let classified = classify_artifact_name(&candidate.basename, SccmRole::SiteServer); classified .supported_for_diagnosis .then(|| SccmArtifactRequest { @@ -1259,17 +1545,68 @@ fn is_token_boundary(character: char) -> bool { character.is_whitespace() || matches!(character, ',' | ';' | '&') } -fn validated_identifier(value: &str) -> Option { - (!value.is_empty() - && value.len() <= 128 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))) +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_ascii_whitespace().all(|token| { + let token = token.trim_matches(|character| matches!(character, ',' | ';' | '&')); + 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 { - evidence.evidence_id == evidence.reference.entry_id + 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 @@ -1292,6 +1629,77 @@ fn unique_evidence_identities(evidence: &[SccmEvidence]) -> Vec { 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 { + type CoverageKey = (String, String, String, String); + + let mut expected = BTreeMap::>::new(); + for artifact in &intake.artifacts { + if SiteCoreGroup::from_source_id(&artifact.source_id).is_none() { + continue; + } + expected + .entry(( + role_sort_key(&artifact.producer_role).to_owned(), + artifact + .workflow_subject_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default() + .to_owned(), + artifact.source_id.clone(), + coverage_sort_key(&artifact.state).to_owned(), + )) + .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(( + role_sort_key(&coverage.producer_role).to_owned(), + coverage + .workflow_subject_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default() + .to_owned(), + coverage.source_id.clone(), + coverage_sort_key(&coverage.state).to_owned(), + )) + .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() { @@ -1384,3 +1792,21 @@ fn role_sort_key(role: &SccmRole) -> &str { 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/tests/fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-core/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/component-failure/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log 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 index 9ae3b814a..28545de8a 100644 --- 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 @@ -1,18 +1,26 @@ { - "expectedContractVersion": 1, - "scenario": "component-failure", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [ { - "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-COMPFAIL-001", + "resultId": "site-core:result:v1:a3220d3e9eebc073daf7575a24654c9c3174140ad298b9b2d72b4ebfa95b957d", "transactionKey": { "profileId": "sccm-site-core", "profileVersion": 1, - "siteCode": "LAB", + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", "componentId": "SMS_EXECUTIVE", "workItemId": "SC-COMPFAIL-001" }, @@ -23,60 +31,156 @@ "confidenceCeiling": "high", "evidence": [ { - "artifactId": "component-failure-sitecomp-current", - "entryId": "component-failure-sitecomp-current:1-1", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "component-failure-sitecomp-current", - "entryId": "component-failure-sitecomp-current:2-2", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", "lineStart": 2, "lineEnd": 2 }, { - "artifactId": "component-failure-sitecomp-current", - "entryId": "component-failure-sitecomp-current:3-3", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", "lineStart": 3, "lineEnd": 3, "terminal": true } ], - "coverageGapArtifactIds": [ - "component-failure-statmgr-absent" - ], + "coverageGapArtifactIds": ["z-site-status"], "nextArtifacts": [] } ], - "unlinkedObservations": [], + "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": "component-failure-statmgr-absent", + "artifactId": "z-site-status", + "sourceId": "server-status", "state": "absent", + "reasonCode": "required-source-absent", "diagnosticMeaning": "coverageOnly" } ], - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" + "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" + } + } ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "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 index dcf518d11..f8192b1a0 100644 --- 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 @@ -1,51 +1,53 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "component-failure", + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" + "siteCode": "LAB", + "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "component-failure-sitecomp-current", - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current" + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:site-default" }, - "rotationLineage": "sitecomp.log", + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:11:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "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": "component-failure-statmgr-absent", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "statmgr.log", - "configuredPath": false, - "rotation": { - "kind": "current" + "configuredPathProvenance": { + "state": "defaultCandidate", + "pathFingerprint": "synthetic:path:z-site" }, - "rotationLineage": "statmgr.log", + "defaultCandidateState": "absentCandidateOnly", + "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "absent", - "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T14:11:00Z", - "encoding": null, "relativePath": null, "bytesCopied": 0 } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-core/status/current/statmgr.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/contradictory/evidence/sccm/server/site-server/server-status/current/statmgr.log 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 index 9d26c212c..14488c594 100644 --- 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 @@ -1,18 +1,65 @@ { - "expectedContractVersion": 1, - "scenario": "contradictory", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [ { - "resultId": "site-core/LAB/SMS_DISTRIBUTION_MANAGER/SC-CONTRA-DIST-001", + "resultId": "site-core:result:v1:17a3f55ffd5eb73fa686eb49ba0ee620d965de9a33ba240d7e87cba666952884", "transactionKey": { "profileId": "sccm-site-core", "profileVersion": 1, - "siteCode": "LAB", + "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" }, @@ -23,107 +70,90 @@ "confidenceCeiling": "high", "evidence": [ { - "artifactId": "contradictory-sitecomp-current", - "entryId": "contradictory-sitecomp-current:2-2", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", "lineStart": 2, "lineEnd": 2 }, { - "artifactId": "contradictory-sitecomp-current", - "entryId": "contradictory-sitecomp-current:3-3", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", "lineStart": 3, "lineEnd": 3 }, { - "artifactId": "contradictory-sitecomp-current", - "entryId": "contradictory-sitecomp-current:5-5", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:5-5", "lineStart": 5, "lineEnd": 5 }, { - "artifactId": "contradictory-statmgr-current", - "entryId": "contradictory-statmgr-current:1-1", + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "contradictory-statmgr-current", - "entryId": "contradictory-statmgr-current:2-2", + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", "lineStart": 2, "lineEnd": 2 } ], "coverageGapArtifactIds": [], "nextArtifacts": [] - }, + } + ], + "unlinkedObservations": [], + "coverageGaps": [], + "findings": [ { - "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-CONTRA-EXEC-001", - "transactionKey": { - "profileId": "sccm-site-core", - "profileVersion": 1, - "siteCode": "LAB", - "componentId": "SMS_EXECUTIVE", - "workItemId": "SC-CONTRA-EXEC-001" - }, - "state": "terminalFailure", - "lastSuccessfulPhase": "componentWork", - "findingClass": "confirmedFailure", + "findingId": "site-core:finding:v1:0dc933141f9f40354f35c580303bdb6c5b47f287e5829f055620a73e75277650", + "class": "confirmedFailure", + "phase": "componentWork", + "role": "siteServer", + "severity": "Error", "confidence": "high", - "confidenceCeiling": "high", + "title": "Site component and status evidence", + "summary": "The last confirmed successful phase is componentWork; later phases are bounded to cited evidence.", "evidence": [ { - "artifactId": "contradictory-sitecomp-current", - "entryId": "contradictory-sitecomp-current:1-1", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "contradictory-sitecomp-current", - "entryId": "contradictory-sitecomp-current:4-4", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:4-4", "lineStart": 4, "lineEnd": 4 }, { - "artifactId": "contradictory-sitecomp-current", - "entryId": "contradictory-sitecomp-current:6-6", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:6-6", "lineStart": 6, - "lineEnd": 6, - "terminal": true + "lineEnd": 6 } ], - "coverageGapArtifactIds": [], - "nextArtifacts": [] + "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" } ], - "unlinkedObservations": [], - "coverageGaps": [], - "adversarialAssertions": { - "resultCount": 2, - "sameMinuteMayMerge": false, - "crossComponentRecovery": false, - "timeOnlyCausalClaim": false - }, - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" - ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "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 index 3d8f5dadd..2ecf8f066 100644 --- 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 @@ -1,52 +1,45 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "contradictory", - "topology": { - "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" - }, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "contradictory-sitecomp-current", - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current" - }, - "rotationLineage": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:51:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "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": "contradictory-statmgr-current", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "statmgr.log", - "configuredPath": true, - "rotation": { - "kind": "current" - }, - "rotationLineage": "statmgr.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:z-site" }, + "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:51:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/status/current/statmgr.log", + "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-core/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/status/current/statmgr.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-server/server-status/current/statmgr.log 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 index 7a2fa2ec5..9e1f6346d 100644 --- 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 @@ -1,18 +1,26 @@ { - "expectedContractVersion": 1, - "scenario": "healthy", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [ { - "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-HEALTH-001", + "resultId": "site-core:result:v1:4333cffa83f498b6a451e36269b164ae23717b1e790f4e0af31beb4821eece3b", "transactionKey": { "profileId": "sccm-site-core", "profileVersion": 1, - "siteCode": "LAB", + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", "componentId": "SMS_EXECUTIVE", "workItemId": "SC-HEALTH-001" }, @@ -23,32 +31,32 @@ "confidenceCeiling": "high", "evidence": [ { - "artifactId": "healthy-sitecomp-current", - "entryId": "healthy-sitecomp-current:1-1", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "healthy-sitecomp-current", - "entryId": "healthy-sitecomp-current:2-2", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", "lineStart": 2, "lineEnd": 2 }, { - "artifactId": "healthy-sitecomp-current", - "entryId": "healthy-sitecomp-current:3-3", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", "lineStart": 3, "lineEnd": 3 }, { - "artifactId": "healthy-statmgr-current", - "entryId": "healthy-statmgr-current:1-1", + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "healthy-statmgr-current", - "entryId": "healthy-statmgr-current:2-2", + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", "lineStart": 2, "lineEnd": 2 } @@ -59,27 +67,7 @@ ], "unlinkedObservations": [], "coverageGaps": [], - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" - ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "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 index 4e9380969..01f2112c9 100644 --- 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 @@ -1,52 +1,55 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "healthy", + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" + "siteCode": "LAB", + "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "healthy-sitecomp-current", - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current" + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:site-default" }, - "rotationLineage": "sitecomp.log", + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:01:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "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": "healthy-statmgr-current", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "statmgr.log", - "configuredPath": true, - "rotation": { - "kind": "current" + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:z-site" }, - "rotationLineage": "statmgr.log", + "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:01:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/status/current/statmgr.log", + "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-core/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/inbox-backlog/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log 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 index 5c88ecdb6..a42e67a60 100644 --- 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 @@ -1,18 +1,26 @@ { - "expectedContractVersion": 1, - "scenario": "inbox-backlog", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [ { - "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-BACKLOG-001", + "resultId": "site-core:result:v1:8f00cd22916faa6850e60171e8392ee5dd7aa55efe883c7d76546a3c3d221f1f", "transactionKey": { "profileId": "sccm-site-core", "profileVersion": 1, - "siteCode": "LAB", + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", "componentId": "SMS_EXECUTIVE", "workItemId": "SC-BACKLOG-001" }, @@ -23,42 +31,38 @@ "confidenceCeiling": "low", "evidence": [ { - "artifactId": "inbox-backlog-sitecomp-current", - "entryId": "inbox-backlog-sitecomp-current:1-1", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "inbox-backlog-sitecomp-current", - "entryId": "inbox-backlog-sitecomp-current:2-2", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", "lineStart": 2, "lineEnd": 2 }, { - "artifactId": "inbox-backlog-sitecomp-current", - "entryId": "inbox-backlog-sitecomp-current:3-3", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", "lineStart": 3, "lineEnd": 3, "terminal": false } ], - "coverageGapArtifactIds": [ - "inbox-backlog-statmgr-absent" - ], + "coverageGapArtifactIds": ["z-site-status"], "nextArtifacts": [ { "logicalName": "server-status", "role": "siteServer", "reasonCode": "matching-status-terminal-evidence-missing", - "basenames": [ - "statmgr.log" - ], - "rotations": [ - "current", - "loUnderscore" + "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" } @@ -66,35 +70,144 @@ ] } ], - "unlinkedObservations": [], + "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": "inbox-backlog-statmgr-absent", + "artifactId": "z-site-status", + "sourceId": "server-status", "state": "absent", + "reasonCode": "required-source-absent", "diagnosticMeaning": "coverageOnly" } ], - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" + "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" + } + } ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "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 index c7e4e20be..a3096d550 100644 --- 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 @@ -1,51 +1,53 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "inbox-backlog", + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" + "siteCode": "LAB", + "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "inbox-backlog-sitecomp-current", - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current" + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:site-default" }, - "rotationLineage": "sitecomp.log", + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:21:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "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": "inbox-backlog-statmgr-absent", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "statmgr.log", - "configuredPath": false, - "rotation": { - "kind": "current" + "configuredPathProvenance": { + "state": "defaultCandidate", + "pathFingerprint": "synthetic:path:z-site" }, - "rotationLineage": "statmgr.log", + "defaultCandidateState": "absentCandidateOnly", + "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "absent", - "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T14:21:00Z", - "encoding": null, "relativePath": null, "bytesCopied": 0 } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/evidence/sccm/server/site-core/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/expected.json index 933adcc51..b72411f39 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/expected.json @@ -1,112 +1,232 @@ { - "expectedContractVersion": 1, - "scenario": "incomplete", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, - "results": [ + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], + "results": [], + "unlinkedObservations": [ { - "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-INCOMPLETE-001", - "transactionKey": { - "profileId": "sccm-site-core", - "profileVersion": 1, - "siteCode": "LAB", - "componentId": "SMS_EXECUTIVE", - "workItemId": "SC-INCOMPLETE-001" - }, - "state": "incomplete", - "lastSuccessfulPhase": "componentWork", + "observationId": "site-core:observation:v1:20750165151067511511fa5d55ec29a0891eba7992f4e1b23a4eafd132ca9879", + "state": "parseGap", "findingClass": "insufficientEvidence", "confidence": "none", - "confidenceCeiling": "none", - "evidence": [ - { - "artifactId": "incomplete-sitecomp-capped", - "entryId": "incomplete-sitecomp-capped:1-1", - "lineStart": 1, - "lineEnd": 1 - }, - { - "artifactId": "incomplete-sitecomp-capped", - "entryId": "incomplete-sitecomp-capped:2-2", - "lineStart": 2, - "lineEnd": 2 - } - ], - "coverageGapArtifactIds": [ - "incomplete-sitecomp-capped", - "incomplete-statesys-absent", - "incomplete-statmgr-access-denied" - ], + "evidence": [], + "coverageGapArtifactIds": ["sitecomp-current"], "nextArtifacts": [ { "logicalName": "server-sitecomp", "role": "siteServer", "reasonCode": "capped-before-next-phase", - "basenames": [ - "sitecomp.log" - ], - "rotations": [ - "current" - ], + "candidates": [{ "basename": "sitecomp.log", "rotation": "current" }], "maxArtifacts": 1, "maxBytesPerArtifact": 4096, "scope": { - "componentId": "SMS_EXECUTIVE", - "workItemId": "SC-INCOMPLETE-001" + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-lab" + } + } + ] + }, + { + "observationId": "site-core:observation:v1:54d36c8102796161d9f08cf1b504631c3268a5d3d5617cfdece01908c2b4a8db", + "state": "parseGap", + "findingClass": "insufficientEvidence", + "confidence": "none", + "evidence": [], + "coverageGapArtifactIds": ["b-sitecomp"], + "nextArtifacts": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-absent", + "candidates": [{ "basename": "statesys.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-a" + } + } + ] + }, + { + "observationId": "site-core:observation:v1:9edbdd94bfcb99682d8938a3eceb1122760ee2aadeb9b9fad5dca1a4d5544005", + "state": "parseGap", + "findingClass": "insufficientEvidence", + "confidence": "none", + "evidence": [], + "coverageGapArtifactIds": ["z-site-status"], + "nextArtifacts": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-access-denied", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" } } ] } ], - "unlinkedObservations": [], "coverageGaps": [ { - "artifactId": "incomplete-sitecomp-capped", - "state": "capped", - "diagnosticMeaning": "coverageOnly", - "evidence": { - "artifactId": "incomplete-sitecomp-capped", - "entryId": "incomplete-sitecomp-capped:3-3", - "lineStart": 3, - "lineEnd": 3, - "completeLogicalRecord": false - } + "artifactId": "b-sitecomp", + "sourceId": "server-status", + "state": "absent", + "reasonCode": "required-source-absent", + "diagnosticMeaning": "coverageOnly" }, { - "artifactId": "incomplete-statesys-absent", - "state": "absent", + "artifactId": "sitecomp-current", + "sourceId": "server-sitecomp", + "state": "capped", + "reasonCode": "required-source-capped", "diagnosticMeaning": "coverageOnly" }, { - "artifactId": "incomplete-statmgr-access-denied", + "artifactId": "z-site-status", + "sourceId": "server-status", "state": "accessDenied", + "reasonCode": "required-source-access-denied", "diagnosticMeaning": "coverageOnly" } ], - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" + "findings": [ + { + "findingId": "site-core:finding:v1:f128f013625df6f648e3bcbea04797dd865613144d069349b2094a760b0ae2c0", + "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": "sitecomp-current", + "role": "siteServer", + "coverage": "capped" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "sitecomp", + "role": "siteServer", + "reason": "Collect the complete sitecomp.log file." + } + ], + "subjectId": "site-core:observation:v1:20750165151067511511fa5d55ec29a0891eba7992f4e1b23a4eafd132ca9879", + "lastSuccessfulPhase": null + }, + { + "findingId": "site-core:finding:v1:044cb34792a9fc91e1f780871421b55859c60ee5dbbb1788ed0a87be772cedfe", + "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": "b-sitecomp", + "role": "siteServer", + "coverage": "absent" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "statesys", + "role": "siteServer", + "reason": "Collect the complete statesys.log file." + } + ], + "subjectId": "site-core:observation:v1:54d36c8102796161d9f08cf1b504631c3268a5d3d5617cfdece01908c2b4a8db", + "lastSuccessfulPhase": null + }, + { + "findingId": "site-core:finding:v1:e8826b8f7c9bcdfd45124cd11eed064bbf61b799edcaa022c7a3eff210ed8966", + "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": "accessDenied" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "statmgr", + "role": "siteServer", + "reason": "Collect the complete statmgr.log file." + } + ], + "subjectId": "site-core:observation:v1:9edbdd94bfcb99682d8938a3eceb1122760ee2aadeb9b9fad5dca1a4d5544005", + "lastSuccessfulPhase": null + } + ], + "artifactRequests": [ + { + "logicalName": "server-sitecomp", + "role": "siteServer", + "reasonCode": "capped-before-next-phase", + "candidates": [{ "basename": "sitecomp.log", "rotation": "current" }], + "maxArtifacts": 1, + "maxBytesPerArtifact": 4096, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-lab" + } + }, + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-absent", + "candidates": [{ "basename": "statesys.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-a" + } + }, + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-access-denied", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" + } + } ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "crossSideCorrelationPerformed": false } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/manifest.json index e8ff810bc..d82244b65 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/incomplete/manifest.json @@ -1,72 +1,62 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "incomplete", - "topology": { - "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" - }, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "incomplete-sitecomp-capped", - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current", - "fragmentComplete": false - }, - "rotationLineage": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "capped", - "captureLimitBytes": 834, - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T15:11:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "collectionLimit": { "byteLimit": 834, "limitApplied": true }, + "truncated": true, + "fragmentComplete": false, + "collectedUtc": "2026-07-30T15:11:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", "bytesCopied": 834 }, { - "artifactId": "incomplete-statesys-absent", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", - "originalBasename": "statesys.log", - "configuredPath": false, - "rotation": { - "kind": "current" - }, - "rotationLineage": "statesys.log", - "captureState": "absent", "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": "accessDenied", + "collectionDetail": "synthetic permission denial", "collectedUtc": "2026-07-30T15:11:00Z", - "encoding": null, "relativePath": null, "bytesCopied": 0 }, { - "artifactId": "incomplete-statmgr-access-denied", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "b-sitecomp", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", - "originalBasename": "statmgr.log", - "configuredPath": true, - "rotation": { - "kind": "current" - }, - "rotationLineage": "statmgr.log", - "captureState": "accessDenied", "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", + "originalBasename": "statesys.log", + "configuredPathProvenance": { "state": "defaultCandidate", "pathFingerprint": "synthetic:path:a-site" }, + "defaultCandidateState": "absentCandidateOnly", + "rotation": { "kind": "current", "lineageId": "sitecomp-a" }, + "captureState": "absent", "collectedUtc": "2026-07-30T15:11:00Z", - "encoding": null, "relativePath": null, "bytesCopied": 0 } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/evidence/sccm/server/site-core/status/current/statmgr.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/evidence/sccm/server/site-server/server-status/current/statmgr.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/evidence/sccm/server/site-core/status/current/statmgr.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/evidence/sccm/server/site-server/server-status/current/statmgr.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/expected.json index 20cc200ff..c07c77a59 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/expected.json @@ -1,46 +1,37 @@ { - "expectedContractVersion": 1, - "scenario": "malformed", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [], "unlinkedObservations": [ { - "observationId": "malformed-status-record", + "observationId": "site-core:observation:v1:11120408c9126ff56585ab7f779a4fa8d13bb6867cf806f3674d16e4e1c9afd6", "state": "parseGap", - "lastSuccessfulPhase": null, - "findingClass": "symptom", - "confidence": "low", - "confidenceCeiling": "low", - "evidence": [ - { - "artifactId": "malformed-statmgr-current", - "entryId": "malformed-statmgr-current:1-1", - "lineStart": 1, - "lineEnd": 1, - "completeLogicalRecord": false - } - ], - "coverageGapArtifactIds": [ - "malformed-statmgr-current" - ], + "findingClass": "insufficientEvidence", + "confidence": "none", + "evidence": [], + "coverageGapArtifactIds": ["z-site-status"], "nextArtifacts": [ { "logicalName": "server-status", "role": "siteServer", - "reasonCode": "complete-status-record-required", - "basenames": [ - "statmgr.log" - ], - "rotations": [ - "current" - ], + "reasonCode": "required-source-parse-failed", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], "maxArtifacts": 1, "scope": { - "rotationLineage": "statmgr.log" + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" } } ] @@ -48,46 +39,56 @@ ], "coverageGaps": [ { - "artifactId": "malformed-statmgr-current", + "artifactId": "z-site-status", + "sourceId": "server-status", "state": "parseFailed", - "diagnosticMeaning": "coverageOnly", - "evidence": { - "artifactId": "malformed-statmgr-current", - "entryId": "malformed-statmgr-current:1-1", - "lineStart": 1, - "lineEnd": 1, - "completeLogicalRecord": false - } + "reasonCode": "required-source-parse-failed", + "diagnosticMeaning": "coverageOnly" } ], - "adversarialAssertions": { - "transactionCount": 0, - "componentKeyAdmitted": false, - "terminalMayBeInferred": false, - "confirmedFailure": false, - "highConfidenceCause": false - }, - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" + "findings": [ + { + "findingId": "site-core:finding:v1:59627c557b8120023694ee3f871c6a6c989d35cdeab47c081e435543283912f1", + "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": "parseFailed" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "statmgr", + "role": "siteServer", + "reason": "Collect the complete statmgr.log file." + } + ], + "subjectId": "site-core:observation:v1:11120408c9126ff56585ab7f779a4fa8d13bb6867cf806f3674d16e4e1c9afd6", + "lastSuccessfulPhase": null + } + ], + "artifactRequests": [ + { + "logicalName": "server-status", + "role": "siteServer", + "reasonCode": "required-source-parse-failed", + "candidates": [{ "basename": "statmgr.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "site-status-z" + } + } ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "crossSideCorrelationPerformed": false } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/manifest.json index d4962f56b..17c14492e 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/malformed/manifest.json @@ -1,35 +1,27 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "malformed", - "topology": { - "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" - }, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "malformed-statmgr-current", - "syntheticFixture": true, - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "statmgr.log", - "configuredPath": true, - "rotation": { - "kind": "current", - "fragmentComplete": false - }, - "rotationLineage": "statmgr.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:z-site" }, + "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T15:21:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/status/current/statmgr.log", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T15:21:00Z", + "relativePath": "evidence/sccm/server/site-server/server-status/current/statmgr.log", "bytesCopied": 215 } ] diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-core/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-core/status/current/statmgr.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/recovery/evidence/sccm/server/site-server/server-status/current/statmgr.log 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 index 26d76c4c1..ad304a594 100644 --- 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 @@ -1,18 +1,26 @@ { - "expectedContractVersion": 1, - "scenario": "recovery", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [ { - "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-RECOVER-001", + "resultId": "site-core:result:v1:6481f8fcb2dc2b2d95e759d00b6c5332fc83f74a8ce808c4079681340c0547a9", "transactionKey": { "profileId": "sccm-site-core", "profileVersion": 1, - "siteCode": "LAB", + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", "componentId": "SMS_EXECUTIVE", "workItemId": "SC-RECOVER-001" }, @@ -23,33 +31,33 @@ "confidenceCeiling": "high", "evidence": [ { - "artifactId": "recovery-sitecomp-current", - "entryId": "recovery-sitecomp-current:1-1", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "recovery-sitecomp-current", - "entryId": "recovery-sitecomp-current:2-2", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", "lineStart": 2, "lineEnd": 2 }, { - "artifactId": "recovery-sitecomp-current", - "entryId": "recovery-sitecomp-current:3-3", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", "lineStart": 3, "lineEnd": 3, "terminal": true }, { - "artifactId": "recovery-statmgr-current", - "entryId": "recovery-statmgr-current:1-1", + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "recovery-statmgr-current", - "entryId": "recovery-statmgr-current:2-2", + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", "lineStart": 2, "lineEnd": 2, "terminal": true, @@ -62,27 +70,56 @@ ], "unlinkedObservations": [], "coverageGaps": [], - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" + "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" + } ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "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 index 712a2edbc..46aa8fd77 100644 --- 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 @@ -1,52 +1,45 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "recovery", - "topology": { - "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" - }, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "recovery-sitecomp-current", - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current" - }, - "rotationLineage": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:43:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "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": "recovery-statmgr-current", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "statmgr.log", - "configuredPath": true, - "rotation": { - "kind": "current" - }, - "rotationLineage": "statmgr.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:z-site" }, + "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:43:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/status/current/statmgr.log", + "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-core/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/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_ similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-core/sitecomp/lo_/sitecomp.lo_ rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/evidence/sccm/server/site-server/server-sitecomp/lo_/sitecomp.lo_ diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/expected.json index 851570d25..c41f5935c 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/expected.json @@ -1,56 +1,60 @@ { - "expectedContractVersion": 1, - "scenario": "rotation-boundary", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [], "unlinkedObservations": [ { - "observationId": "rotation-boundary-fragments", + "observationId": "site-core:observation:v1:451100e2fecf411dab719c39efac17c4a413cc9159154cbf7b8e852b2d5e0d2e", "state": "parseGap", - "lastSuccessfulPhase": null, "findingClass": "insufficientEvidence", "confidence": "none", - "confidenceCeiling": "none", - "evidence": [ - { - "artifactId": "rotation-boundary-sitecomp-current", - "entryId": "rotation-boundary-sitecomp-current:1-1", - "lineStart": 1, - "lineEnd": 1, - "completeLogicalRecord": false - }, + "evidence": [], + "coverageGapArtifactIds": ["b-sitecomp"], + "nextArtifacts": [ { - "artifactId": "rotation-boundary-sitecomp-lo", - "entryId": "rotation-boundary-sitecomp-lo:1-1", - "lineStart": 1, - "lineEnd": 1, - "completeLogicalRecord": false + "logicalName": "server-sitecomp", + "role": "siteServer", + "reasonCode": "required-source-parse-failed", + "candidates": [ + { "basename": "sitecomp.lo_", "rotation": "loUnderscore" } + ], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-lab" + } } - ], - "coverageGapArtifactIds": [ - "rotation-boundary-sitecomp-current", - "rotation-boundary-sitecomp-lo" - ], + ] + }, + { + "observationId": "site-core:observation:v1:e90f28bbfe6504368011dcfad219c8103b931bcaa8e8b599c96c0ea6a04196f1", + "state": "parseGap", + "findingClass": "insufficientEvidence", + "confidence": "none", + "evidence": [], + "coverageGapArtifactIds": ["sitecomp-current"], "nextArtifacts": [ { "logicalName": "server-sitecomp", "role": "siteServer", - "reasonCode": "complete-logical-record-required", - "basenames": [ - "sitecomp.log", - "sitecomp.lo_" - ], - "rotations": [ - "current", - "loUnderscore" - ], - "maxArtifacts": 2, + "reasonCode": "required-source-parse-failed", + "candidates": [{ "basename": "sitecomp.log", "rotation": "current" }], + "maxArtifacts": 1, "scope": { - "rotationLineage": "sitecomp.log" + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-lab" } } ] @@ -58,56 +62,105 @@ ], "coverageGaps": [ { - "artifactId": "rotation-boundary-sitecomp-current", + "artifactId": "b-sitecomp", + "sourceId": "server-sitecomp", "state": "parseFailed", - "diagnosticMeaning": "coverageOnly", - "evidence": { - "artifactId": "rotation-boundary-sitecomp-current", - "entryId": "rotation-boundary-sitecomp-current:1-1", - "lineStart": 1, - "lineEnd": 1 - } + "reasonCode": "required-source-parse-failed", + "diagnosticMeaning": "coverageOnly" }, { - "artifactId": "rotation-boundary-sitecomp-lo", + "artifactId": "sitecomp-current", + "sourceId": "server-sitecomp", "state": "parseFailed", - "diagnosticMeaning": "coverageOnly", - "evidence": { - "artifactId": "rotation-boundary-sitecomp-lo", - "entryId": "rotation-boundary-sitecomp-lo:1-1", - "lineStart": 1, - "lineEnd": 1 - } + "reasonCode": "required-source-parse-failed", + "diagnosticMeaning": "coverageOnly" } ], - "adversarialAssertions": { - "transactionCount": 0, - "phaseMayAdvance": false, - "terminalMayBeInferred": false, - "crossRotationFragmentJoin": false, - "highConfidenceCause": false - }, - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" + "findings": [ + { + "findingId": "site-core:finding:v1:35ab0d0fc326af948cdbeaa4e8510e8dc467bbfeca4d9de9726a9324871b0812", + "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": "b-sitecomp", + "role": "siteServer", + "coverage": "parseFailed" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "sitecomp", + "role": "siteServer", + "reason": "Collect the complete sitecomp.log file." + } + ], + "subjectId": "site-core:observation:v1:451100e2fecf411dab719c39efac17c4a413cc9159154cbf7b8e852b2d5e0d2e", + "lastSuccessfulPhase": null + }, + { + "findingId": "site-core:finding:v1:b526eeb6982092da44b2ade2f032b43f33dfb4910bb69b41c0b4c02f7c862a35", + "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": "sitecomp-current", + "role": "siteServer", + "coverage": "parseFailed" + } + ], + "correlationKeys": [], + "nextArtifacts": [ + { + "logicalId": "sitecomp", + "role": "siteServer", + "reason": "Collect the complete sitecomp.log file." + } + ], + "subjectId": "site-core:observation:v1:e90f28bbfe6504368011dcfad219c8103b931bcaa8e8b599c96c0ea6a04196f1", + "lastSuccessfulPhase": null + } + ], + "artifactRequests": [ + { + "logicalName": "server-sitecomp", + "role": "siteServer", + "reasonCode": "required-source-parse-failed", + "candidates": [ + { "basename": "sitecomp.lo_", "rotation": "loUnderscore" } + ], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-lab" + } + }, + { + "logicalName": "server-sitecomp", + "role": "siteServer", + "reasonCode": "required-source-parse-failed", + "candidates": [{ "basename": "sitecomp.log", "rotation": "current" }], + "maxArtifacts": 1, + "scope": { + "producerHostHandle": "synthetic:host:site-01", + "rotationLineageHandle": "sitecomp-lab" + } + } ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "crossSideCorrelationPerformed": false } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/manifest.json index 6c34b7d51..080f6fd49 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/rotation-boundary/manifest.json @@ -1,56 +1,45 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "rotation-boundary", - "topology": { - "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" - }, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "rotation-boundary-sitecomp-current", - "syntheticFixture": true, - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current", - "fragmentComplete": false - }, - "rotationLineage": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T15:01:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T15:01:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log", "bytesCopied": 216 }, { - "artifactId": "rotation-boundary-sitecomp-lo", - "syntheticFixture": true, - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "b-sitecomp", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.lo_", - "configuredPath": true, - "rotation": { - "kind": "loUnderscore", - "fragmentComplete": false - }, - "rotationLineage": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "lo_", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T15:01:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/lo_/sitecomp.lo_", + "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, + "collectedUtc": "2026-07-30T15:01:00Z", + "relativePath": "evidence/sccm/server/site-server/server-sitecomp/lo_/sitecomp.lo_", "bytesCopied": 196 } ] diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-server/server-sitecomp/current/sitecomp.log diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-core/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-core/status/current/statmgr.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/site_core/status-processing-failure/evidence/sccm/server/site-server/server-status/current/statmgr.log 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 index b7ef1017c..7421e5277 100644 --- 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 @@ -1,18 +1,26 @@ { - "expectedContractVersion": 1, - "scenario": "status-processing-failure", + "schemaVersion": 1, + "workflow": "siteCore", "profile": { "id": "sccm-site-core", "version": 1, "stability": "experimental" }, + "stateChain": [ + "componentStart", + "componentWork", + "inboxOrQueue", + "statusOrStateProcessing", + "healthyOrTerminal" + ], "results": [ { - "resultId": "site-core/LAB/SMS_EXECUTIVE/SC-STATUSFAIL-001", + "resultId": "site-core:result:v1:234575fad725c1b8673aaa8d9c02e5465aad0b919b0de5c4b52b4221098d0eb1", "transactionKey": { "profileId": "sccm-site-core", "profileVersion": 1, - "siteCode": "LAB", + "siteHandle": "synthetic:site:lab", + "producerHostHandle": "synthetic:host:site-01", "componentId": "SMS_EXECUTIVE", "workItemId": "SC-STATUSFAIL-001" }, @@ -23,32 +31,32 @@ "confidenceCeiling": "high", "evidence": [ { - "artifactId": "status-processing-failure-sitecomp-current", - "entryId": "status-processing-failure-sitecomp-current:1-1", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "status-processing-failure-sitecomp-current", - "entryId": "status-processing-failure-sitecomp-current:2-2", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:2-2", "lineStart": 2, "lineEnd": 2 }, { - "artifactId": "status-processing-failure-sitecomp-current", - "entryId": "status-processing-failure-sitecomp-current:3-3", + "artifactId": "sitecomp-current", + "entryId": "sitecomp-current:3-3", "lineStart": 3, "lineEnd": 3 }, { - "artifactId": "status-processing-failure-statmgr-current", - "entryId": "status-processing-failure-statmgr-current:1-1", + "artifactId": "z-site-status", + "entryId": "z-site-status:1-1", "lineStart": 1, "lineEnd": 1 }, { - "artifactId": "status-processing-failure-statmgr-current", - "entryId": "status-processing-failure-statmgr-current:2-2", + "artifactId": "z-site-status", + "entryId": "z-site-status:2-2", "lineStart": 2, "lineEnd": 2, "terminal": true @@ -60,27 +68,66 @@ ], "unlinkedObservations": [], "coverageGaps": [], - "prohibitedClaims": [ - "absentDownstreamRole", - "clientImpact", - "crossSideCausality" + "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" + } ], - "ordering": { - "resultsBy": [ - "resultId" - ], - "evidenceBy": [ - "artifactId", - "lineStart", - "lineEnd" - ], - "coverageGapsBy": [ - "artifactId" - ], - "nextArtifactsBy": [ - "logicalName", - "role", - "reasonCode" - ] - } + "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 index 4e9e2a109..bd4375e95 100644 --- 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 @@ -1,52 +1,45 @@ { "sccmManifestVersion": 1, - "bundleRole": "server", "syntheticFixture": true, - "scenario": "status-processing-failure", - "topology": { - "captureHost": "LAB-CM01", - "rolesObserved": [ - "siteServer" - ], - "siteCode": "LAB" - }, + "proposalOnly": true, + "privacy": { "synthetic": true, "rawPaths": "redacted" }, + "bundleRole": "server", + "topology": { "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": ["siteServer"] }, "artifacts": [ { - "artifactId": "status-processing-failure-sitecomp-current", - "role": "siteServer", - "sourceGroup": "server-sitecomp", + "artifactId": "sitecomp-current", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-sitecomp", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "sitecomp.log", - "configuredPath": true, - "rotation": { - "kind": "current" - }, - "rotationLineage": "sitecomp.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:site-default" }, + "rotation": { "kind": "current", "lineageId": "sitecomp-lab" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:31:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/sitecomp/current/sitecomp.log", + "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": "status-processing-failure-statmgr-current", - "role": "siteServer", - "sourceGroup": "server-status", + "artifactId": "z-site-status", + "producerRole": "siteServer", + "producerHostHandle": "synthetic:host:site-01", + "sourceId": "server-status", "sourceKind": "ccmLog", - "originalPath": "REDACTED", + "sourceVersion": "5.00.TEST", + "originalPath": "REDACTED_SITE_ROOT", "originalBasename": "statmgr.log", - "configuredPath": true, - "rotation": { - "kind": "current" - }, - "rotationLineage": "statmgr.log", + "configuredPathProvenance": { "state": "configured", "pathFingerprint": "synthetic:path:z-site" }, + "rotation": { "kind": "current", "lineageId": "site-status-z" }, "captureState": "captured", - "sourceVersion": "5.00.TEST", - "collectedUtc": "2026-07-30T14:31:00Z", "encoding": "utf-8", - "relativePath": "evidence/sccm/server/site-core/status/current/statmgr.log", + "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/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index 91cd8fb27..408852036 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -11,40 +11,40 @@ use std::fs; use std::path::{Path, PathBuf}; const HEALTHY_SITECOMP: &str = include_str!( - "fixtures/sccm/server/site_core/healthy/evidence/sccm/server/site-core/sitecomp/current/sitecomp.log" + "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-core/status/current/statmgr.log" + "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-core/sitecomp/current/sitecomp.log" + "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-core/sitecomp/current/sitecomp.log" + "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-core/sitecomp/current/sitecomp.log" + "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-core/status/current/statmgr.log" + "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-core/sitecomp/current/sitecomp.log" + "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-core/status/current/statmgr.log" + "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-core/sitecomp/current/sitecomp.log" + "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-core/status/current/statmgr.log" + "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-core/sitecomp/current/sitecomp.log" + "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-core/sitecomp/lo_/sitecomp.lo_" + "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", @@ -468,8 +468,13 @@ fn terminal_component_and_status_outcomes_require_exact_cited_facts() { component.results[0].finding_class, Some(SccmFindingClass::ConfirmedFailure) ); - assert_eq!(component.findings.len(), 1); - assert_eq!(component.findings[0].finding.terminal_evidence.len(), 1); + 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() @@ -858,10 +863,38 @@ fn closed_profile_schema_rejects_arbitrary_keys_and_retains_safe_unknown_facts() let unknown = analyze_site_core(&unknown_status); let unknown_wire = serde_json::to_string(&unknown).expect("analysis serializes"); assert!(unknown_wire.contains(&rejected_id)); - assert!(unknown.unlinked_observations.iter().any(|observation| { - observation.finding_class == SccmFindingClass::Symptom - || observation.finding_class == SccmFindingClass::InsufficientEvidence - })); + 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] @@ -972,7 +1005,7 @@ fn invalid_finding_inputs_become_explicit_gaps_instead_of_clearing_class() { #[test] fn committed_site_core_corpus_exactly_matches_every_serialized_output() { - for scenario in [ + let scenarios = [ "healthy", "component-failure", "inbox-backlog", @@ -982,8 +1015,12 @@ fn committed_site_core_corpus_exactly_matches_every_serialized_output() { "rotation-boundary", "incomplete", "malformed", - ] { - let (assessment, expected) = load_corpus_scenario(scenario); + ]; + 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"), diff --git a/crates/cmtraceopen-parser/tests/sccm_site_core_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_site_core_fixture_contract.rs index fc0cae2ec..c9173af89 100644 --- a/crates/cmtraceopen-parser/tests/sccm_site_core_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_site_core_fixture_contract.rs @@ -34,7 +34,7 @@ fn coverage_contract_failures(artifact: &Value) -> Vec { if matches!( state, "absent" | "accessDenied" | "capped" | "skipped" | "unsupported" | "parseFailed" - ) && artifact["rotation"]["fragmentComplete"] == true + ) && artifact["fragmentComplete"] == true { vec![format!( "{state} artifact {} cannot be a complete fragment", @@ -179,7 +179,7 @@ fn site_core_uses_canonical_rotation_and_coverage_contracts() { .as_array() .expect("rotation artifacts are an array") .iter() - .find(|artifact| artifact["rotation"]["kind"] == "loUnderscore") + .find(|artifact| artifact["rotation"]["kind"] == "lo_") .expect("rotation corpus has a .lo_ artifact"); let basename = rollover["originalBasename"] @@ -202,18 +202,31 @@ fn site_core_uses_canonical_rotation_and_coverage_contracts() { "fixtures/sccm/server/site_core/rotation-boundary/expected.json" )) .expect("rotation expected output is JSON"); - let requested_basenames = expected["unlinkedObservations"][0]["nextArtifacts"][0]["basenames"] + let requested_candidates = expected["unlinkedObservations"] .as_array() - .expect("rotation request has basenames"); - if requested_basenames + .expect("rotation output has observations") .iter() - .any(|value| value.as_str() == Some("sitecomp.log.lo_")) - || !requested_basenames - .iter() - .any(|value| value.as_str() == Some("sitecomp.lo_")) + .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 sitecomp.lo_, got {requested_basenames:?}" + "rotation-boundary: expected request must use exactly paired sitecomp.lo_/loUnderscore, got {requested_candidates:?}" )); } @@ -225,7 +238,8 @@ fn capped_artifact_cannot_claim_a_complete_fragment() { let artifact = serde_json::json!({ "artifactId": "capped-probe", "captureState": "capped", - "rotation": {"kind": "current", "fragmentComplete": true} + "rotation": {"kind": "current"}, + "fragmentComplete": true }); assert_eq!(coverage_contract_failures(&artifact).len(), 1); @@ -287,7 +301,8 @@ fn nonphysical_states_cannot_claim_files_or_complete_fragments() { "captureState": state, "relativePath": "evidence/placeholder.log", "bytesCopied": 1, - "rotation": {"kind": "current", "fragmentComplete": true} + "rotation": {"kind": "current"}, + "fragmentComplete": true }); assert_eq!( From bb434229c18633e56a72ae94742d63c4cab81029 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 05:05:43 -0400 Subject: [PATCH 282/422] test(sccm): reject delimiter-adjacent profile labels --- .../tests/sccm_server_site_core.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index 408852036..e7ecbc38f 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -319,6 +319,45 @@ fn assert_explicit_gap_and_request( })); } +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!(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") } @@ -897,6 +936,46 @@ fn closed_profile_schema_rejects_arbitrary_keys_and_retains_safe_unknown_facts() 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 in [ From ded859a7fd11957f040427fc021f96a0c200587d Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 05:09:09 -0400 Subject: [PATCH 283/422] fix(sccm): close delimiter-separated profile fields --- crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs index 7ad171f67..56e549507 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs @@ -1551,8 +1551,7 @@ fn is_profile_record_candidate(message: &str) -> bool { } fn profile_labels_are_closed(message: &str) -> bool { - message.split_ascii_whitespace().all(|token| { - let token = token.trim_matches(|character| matches!(character, ',' | ';' | '&')); + message.split(is_token_boundary).all(|token| { let Some((label, _)) = token.split_once('=') else { return true; }; From 8a0a603e381aef74fdff152eced71ec087b81c4c Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 05:15:27 -0400 Subject: [PATCH 284/422] test(sccm): pin native manifest reader contract (#319) --- src-tauri/Cargo.toml | 5 + src-tauri/tests/sccm_client_manifest.rs | 476 ++++++++++++++++++++++++ 2 files changed, 481 insertions(+) create mode 100644 src-tauri/tests/sccm_client_manifest.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index aa1ab401f..4f09dadcb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" } @@ -127,6 +128,10 @@ required-features = ["esp-diagnostics"] name = "sysmon_parser" required-features = ["sysmon"] +[[test]] +name = "sccm_client_manifest" +required-features = ["sccm-diagnostics"] + [[bench]] name = "intune_pipeline" harness = false diff --git a/src-tauri/tests/sccm_client_manifest.rs b/src-tauri/tests/sccm_client_manifest.rs new file mode 100644 index 000000000..26e01b4dd --- /dev/null +++ b/src-tauri/tests/sccm_client_manifest.rs @@ -0,0 +1,476 @@ +use std::fs; +use std::path::Path; + +use app_lib::sccm::{ + manifest_to_client_intake_bundle, 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::{assess_client_intake, 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 { + format!( + "sccm-client-source:v1:sha256:{}", + sha256(POLICY_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) + ) +} + +fn physical_artifact(rotation: SccmRotation, content: &[u8]) -> Value { + 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() + ) + ); + 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" + }) +} + +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: &[Value], capture_gaps: &[Value]) { + make_private_directory(root); + for artifact in artifacts { + let relative_path = artifact["relativePath"] + .as_str() + .expect("physical fixture path"); + let basename = artifact["basename"] + .as_str() + .expect("physical fixture basename"); + let content = match basename { + POLICY_BASENAME => b"policy-current".as_slice(), + "PolicyAgent.log.1" => b"policy-rotation-one".as_slice(), + "PolicyAgent.log.2" => b"policy-rotation-two".as_slice(), + _ => panic!("unexpected physical fixture basename"), + }; + let destination = root.join(relative_path); + fs::create_dir_all(destination.parent().expect("evidence parent")) + .expect("create evidence tree"); + fs::write(destination, content).expect("write synthetic evidence"); + } + let manifest = native_manifest(artifacts.to_vec(), 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"); +} + +#[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, std::slice::from_ref(¤t), &[]); + + let manifest = read_sccm_manifest_or_legacy(&bundle_root).expect("validated manifest"); + let bundle = manifest_to_client_intake_bundle(&manifest).expect("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 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, + std::slice::from_ref(¤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 manifest_value = native_manifest( + vec![physical_artifact(SccmRotation::Current, b"policy-current")], + vec![capture_gap(SccmRotation::Numbered(2), "parseFailed")], + ); + let manifest: SccmBundleManifestV1 = + serde_json::from_value(manifest_value).expect("manifest shape"); + let bundle = manifest_to_client_intake_bundle(&manifest).expect("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")], + 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")], + 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, current], &[]); + + 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_and_relative_paths() { + 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, &[current.clone(), current], &[]); + + 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() { + 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": [] } + }, + "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()); +} + +#[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())); +} + +#[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, std::slice::from_ref(¤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], 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 mut value = native_manifest( + vec![physical_artifact(SccmRotation::Current, b"policy-current")], + vec![], + ); + value["artifacts"][0]["state"] = json!("absent"); + let manifest: SccmBundleManifestV1 = serde_json::from_value(value).expect("wire shape"); + + let error = manifest_to_client_intake_bundle(&manifest) + .expect_err("nonphysical state cannot claim a file"); + assert!(error.to_string().contains("nonphysical")); + assert_ne!( + manifest.artifacts[0].state, + SccmManifestSourceState::Captured + ); +} From c9cfc3aff4066a862ba3f671ca2ddb041e935fb5 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 05:20:20 -0400 Subject: [PATCH 285/422] test(sccm): expose site core review gaps --- .../tests/sccm_server_site_core.rs | 186 ++++++++++++++---- 1 file changed, 151 insertions(+), 35 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index e7ecbc38f..1d49b6238 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -1,6 +1,7 @@ use cmtraceopen_parser::sccm::server::windows::{ analyze_site_core, assess_server_intake, SccmServerArtifactPayload, SccmServerIntakeAssessment, - SccmSiteCoreAnalysis, SccmSiteCoreConfidence, SccmSiteCorePhase, SccmSiteCoreState, + SccmSiteCoreAnalysis, SccmSiteCoreArtifactRequest, SccmSiteCoreConfidence, SccmSiteCorePhase, + SccmSiteCoreState, }; use cmtraceopen_parser::sccm::{ SccmCoverageState, SccmFindingClass, SccmRole, SccmRotation, SccmTimeOrderingState, @@ -291,12 +292,37 @@ fn assess(sources: &[Source<'_>]) -> SccmServerIntakeAssessment { .expect("site-core test manifest must pass the shared server intake") } -fn classifications(assessment: &SccmServerIntakeAssessment) -> Vec> { - analyze_site_core(assessment) - .results - .into_iter() - .map(|result| result.finding_class) - .collect() +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_explicit_gap_and_request( @@ -314,9 +340,12 @@ fn assert_explicit_gap_and_request( .iter() .any(|candidate| candidate == artifact_id) })); - assert!(analysis.artifact_requests.iter().any(|request| { - request.logical_name == source_id && request.max_artifacts > 0 && request.max_artifacts <= 2 - })); + let request = analysis + .artifact_requests + .iter() + .find(|request| request.logical_name == source_id) + .expect("coverage gap has a source-specific artifact request"); + assert_bounded_request_has_specific_scope(request); } fn assert_delimiter_attached_unknown_label_fails_closed(delimiter: char) { @@ -345,6 +374,7 @@ fn assert_delimiter_attached_unknown_label_fails_closed(delimiter: char) { 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 @@ -444,10 +474,7 @@ fn configured_nondefault_sources_supersede_absent_default_candidates() { 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 - .iter() - .all(|gap| gap.artifact_id != "b-sitecomp")); + assert!(analysis.coverage_gaps.is_empty()); assert!(analysis.findings.is_empty()); assert!(analysis.artifact_requests.is_empty()); } @@ -636,11 +663,15 @@ fn unrelated_same_minute_components_and_producer_hosts_never_merge() { assert!(foreign_gap_analysis.results[0] .coverage_gap_artifact_ids .is_empty()); + 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] @@ -711,6 +742,22 @@ fn encoding_profile_coverage_fragment_cap_and_time_provenance_fail_closed() { ("time", invalid_time), ] { 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) @@ -731,6 +778,7 @@ fn rotation_split_fragments_are_coverage_not_a_terminal_transaction() { Source::sitecomp(ROTATION_CURRENT_FRAGMENT), Source::sitecomp_lo_fragment(ROTATION_LO_FRAGMENT), ]); + assert_eq!(assessment.artifacts.len(), 2); assert!(assessment .artifacts .iter() @@ -743,6 +791,7 @@ fn rotation_split_fragments_are_coverage_not_a_terminal_transaction() { .coverage_gaps .iter() .all(|gap| gap.state == SccmCoverageState::ParseFailed)); + assert_eq!(analysis.findings.len(), 2); assert!(analysis .findings .iter() @@ -763,6 +812,7 @@ fn incomplete_sources_are_coverage_states_not_role_health_claims() { 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() @@ -800,9 +850,18 @@ fn no_provenance_mutation_can_reintroduce_a_confirmed_failure() { .expect("captured source provenance") .limit_applied = true; - assert!(classifications(&assessment) - .into_iter() - .all(|class| class != Some(SccmFindingClass::ConfirmedFailure))); + let analysis = analyze_site_core(&assessment); + assert!(analysis.results.is_empty()); + assert!(analysis + .coverage_gaps + .iter() + .any(|gap| gap.artifact_id == "sitecomp-current")); + let request = analysis + .artifact_requests + .iter() + .find(|request| request.logical_name == "server-sitecomp") + .expect("provenance coverage gap has a request"); + assert_bounded_request_has_specific_scope(request); } #[test] @@ -852,25 +911,83 @@ fn rejected_role_subject_and_duplicate_sources_become_explicit_parse_gaps() { "sitecomp-current", "server-sitecomp", ); + + 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_explicit_gap_and_request( + &analyze_site_core(&rejected_shape), + "z-site-status", + "server-status", + ); } #[test] -fn colliding_evidence_identities_are_parse_gaps_not_silent_drops() { - let mut assessment = assess(&[ +fn rejected_evidence_contracts_become_source_gaps_with_scoped_requests() { + let healthy = assess(&[ Source::sitecomp(HEALTHY_SITECOMP), Source::status(HEALTHY_STATUS), ]); - let duplicate = assessment.evidence[0].clone(); - assessment.evidence.push(duplicate); - let analysis = analyze_site_core(&assessment); + let mut wrong_role = healthy.clone(); + wrong_role.evidence[0].role = SccmRole::ManagementPoint; + let analysis = analyze_site_core(&wrong_role); assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); + assert_eq!(analysis.results.len(), 1); + assert!(analysis.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + || result.confidence != SccmSiteCoreConfidence::High + })); + + let mut incomplete_reference = healthy.clone(); + incomplete_reference.evidence[0].reference.line_end = None; + let analysis = analyze_site_core(&incomplete_reference); + assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); + assert_eq!(analysis.results.len(), 1); + assert!(analysis.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + || result.confidence != SccmSiteCoreConfidence::High + })); + + let mut cross_source_reference = healthy.clone(); + cross_source_reference.evidence[0].reference.artifact_id = "z-site-status".to_owned(); + let analysis = analyze_site_core(&cross_source_reference); + assert_explicit_gap_and_request(&analysis, "z-site-status", "server-status"); + assert_eq!(analysis.results.len(), 1); + assert!(analysis.results.iter().all(|result| { + result.state != SccmSiteCoreState::Healthy + || result.confidence != SccmSiteCoreConfidence::High + })); + + let mut unresolved_reference = healthy; + unresolved_reference.evidence[0].reference.artifact_id = "orphan-sitecomp-record".to_owned(); + let analysis = analyze_site_core(&unresolved_reference); + assert_explicit_gap_and_request(&analysis, "orphan-sitecomp-record", "server-sitecomp"); + assert_eq!(analysis.results.len(), 1); assert!(analysis.results.iter().all(|result| { result.state != SccmSiteCoreState::Healthy || result.confidence != SccmSiteCoreConfidence::High })); } +#[test] +fn colliding_evidence_identities_are_parse_gaps_not_silent_drops() { + let mut assessment = assess(&[Source::sitecomp(HEALTHY_SITECOMP), Source::absent_status()]); + let duplicate = assessment.evidence[0].clone(); + assessment.evidence.push(duplicate); + + let analysis = analyze_site_core(&assessment); + assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); + assert!(analysis.results.is_empty()); +} + #[test] fn closed_profile_schema_rejects_arbitrary_keys_and_retains_safe_unknown_facts() { let mut arbitrary_work = assess(&[ @@ -884,7 +1001,10 @@ fn closed_profile_schema_rejects_arbitrary_keys_and_retains_safe_unknown_facts() } let arbitrary = analyze_site_core(&arbitrary_work); assert!(arbitrary.results.is_empty()); - assert!(!arbitrary.unlinked_observations.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 @@ -1016,6 +1136,7 @@ fn every_required_source_coverage_state_emits_insufficient_evidence_and_a_reques .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) @@ -1075,9 +1196,7 @@ fn invalid_finding_inputs_become_explicit_gaps_instead_of_clearing_class() { } let analysis = analyze_site_core(&assessment); - assert!(analysis.results.iter().all(|result| { - result.finding_class.is_some() || result.state == SccmSiteCoreState::Healthy - })); + assert!(analysis.results.is_empty()); assert!(!analysis.unlinked_observations.is_empty()); assert!(!analysis.artifact_requests.is_empty()); } @@ -1147,6 +1266,7 @@ fn rotation_provenance_must_match_classification_and_requests_use_exact_pairs() .rotation = Some(SccmRotation::LoUnderscore); let rejected = analyze_site_core(&mismatch); assert_explicit_gap_and_request(&rejected, "sitecomp-current", "server-sitecomp"); + assert_eq!(rejected.results.len(), 1); assert!(rejected.results.iter().all(|result| { result.state != SccmSiteCoreState::Healthy || result.confidence != SccmSiteCoreConfidence::High @@ -1186,11 +1306,10 @@ fn rotation_provenance_must_match_classification_and_requests_use_exact_pairs() value: None, })); let unknown = analyze_site_core(&unknown_rotation); - assert!(unknown.artifact_requests.iter().all(|request| { - serde_json::to_value(request).expect("request serializes")["candidates"] - .as_array() - .is_some_and(|candidates| !candidates.is_empty()) - })); + assert!(!unknown.artifact_requests.is_empty()); + for request in &unknown.artifact_requests { + assert_bounded_request_has_specific_scope(request); + } } #[test] @@ -1202,10 +1321,7 @@ fn intake_coverage_must_be_congruent_before_facts_can_shape_results() { assessment.coverage.clear(); let analysis = analyze_site_core(&assessment); - assert!(analysis.results.iter().all(|result| { - result.state != SccmSiteCoreState::Healthy - || result.confidence != SccmSiteCoreConfidence::High - })); + assert!(analysis.results.is_empty()); assert!(!analysis.coverage_gaps.is_empty()); assert!(!analysis.unlinked_observations.is_empty()); assert!(!analysis.artifact_requests.is_empty()); From daa7e0b24ca6e0170163b9e736c0fd90897a23ee Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 05:25:30 -0400 Subject: [PATCH 286/422] fix(sccm): reject unscoped site core evidence --- .../src/sccm/server/windows/site_core.rs | 301 ++++++++++++++---- .../tests/sccm_server_site_core.rs | 42 +++ 2 files changed, 285 insertions(+), 58 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs index 56e549507..de0a1fe27 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs @@ -294,6 +294,7 @@ struct AdmittedSource<'a> { } struct SiteCoreContext<'a> { + artifacts: &'a [SccmServerArtifactAssessment], sources: BTreeMap<&'a str, AdmittedSource<'a>>, evidence_identity_is_unique: Vec, coverage_gaps: Vec, @@ -304,10 +305,20 @@ impl<'a> SiteCoreContext<'a> { 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); let coverage_congruent = site_core_coverage_is_congruent(intake); - let sources = admitted_sources(intake, &collision_artifact_ids, coverage_congruent); - let coverage_gaps = collect_coverage_gaps(intake, &sources); + 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, evidence_identity_is_unique, coverage_gaps, @@ -323,11 +334,11 @@ pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAna let Some(source) = context.sources.get(evidence.reference.artifact_id.as_str()) else { continue; }; - if !source.fact_eligible - || evidence.role != SccmRole::SiteServer - || !context.evidence_identity_is_unique[position] - || !reference_is_complete(evidence) - { + if let Some(reason_code) = evidence_record_rejection_reason(evidence, source.group) { + 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) { @@ -415,6 +426,7 @@ pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAna 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(); @@ -442,6 +454,9 @@ fn admitted_sources<'a>( 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 { @@ -464,6 +479,88 @@ fn admitted_sources<'a>( .collect() } +fn evidence_source_rejections( + intake: &SccmServerIntakeAssessment, +) -> (BTreeMap, Vec) { + let mut groups_by_artifact = BTreeMap::<&str, Vec>::new(); + for artifact in &intake.artifacts { + 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: safe_coverage_artifact_id(evidence), + 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 safe_coverage_artifact_id(evidence: &SccmEvidence) -> String { + if 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", @@ -606,6 +703,9 @@ fn collect_coverage_gaps( "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" ) { @@ -627,6 +727,11 @@ fn collect_coverage_gaps( 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) @@ -640,7 +745,6 @@ fn collect_coverage_gaps( && left.state == right.state && left.reason_code == right.reason_code }); - gaps } fn absent_default_is_superseded( @@ -1100,7 +1204,7 @@ fn next_artifacts_for_state( if state == SccmSiteCoreState::BlockedOrDeferred { return vec![status_request( "matching-status-terminal-evidence-missing", - Some(key), + key, )]; } if state != SccmSiteCoreState::Incomplete { @@ -1124,17 +1228,14 @@ fn next_artifacts_for_state( .iter() .any(|fact| fact.marker.group == SiteCoreGroup::Status) { - return vec![status_request( - "matching-status-evidence-missing", - Some(key), - )]; + return vec![status_request("matching-status-evidence-missing", key)]; } Vec::new() } fn status_request( reason_code: &str, - key: Option<&SccmSiteCoreTransactionKey>, + key: &SccmSiteCoreTransactionKey, ) -> SccmSiteCoreArtifactRequest { SccmSiteCoreArtifactRequest { logical_name: SCCM_SITE_CORE_STATUS_GROUP.to_owned(), @@ -1153,9 +1254,9 @@ fn status_request( max_artifacts: MAX_SITE_CORE_REQUEST_ARTIFACTS, max_bytes_per_artifact: None, scope: SccmSiteCoreRequestScope { - producer_host_handle: key.map(|key| key.producer_host_handle.clone()), - component_id: key.map(|key| key.component_id.clone()), - work_item_id: key.map(|key| key.work_item_id.clone()), + 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, }, } @@ -1166,6 +1267,7 @@ fn recapture_request( 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() @@ -1180,14 +1282,7 @@ fn recapture_request( candidates: vec![candidate], max_artifacts: 1, max_bytes_per_artifact: Some(bounded), - scope: SccmSiteCoreRequestScope { - producer_host_handle: key - .map(|key| key.producer_host_handle.clone()) - .or_else(|| artifact.producer_host_handle.clone()), - component_id: key.map(|key| key.component_id.clone()), - work_item_id: key.map(|key| key.work_item_id.clone()), - rotation_lineage_handle: Some(artifact.rotation_lineage_handle.clone()), - }, + scope, }) } @@ -1224,14 +1319,21 @@ fn rejected_record_observation( source: &AdmittedSource<'_>, reason_code: &str, ) -> SccmSiteCoreObservation { - let line_start = evidence.reference.line_start.unwrap_or_default(); - let line_end = evidence.reference.line_end.unwrap_or_default(); - let request = complete_source_request(source.artifact, reason_code).unwrap_or_else(|| { - group_request( - source.group, - reason_code, - source.artifact.producer_host_handle.clone(), - ) + 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( @@ -1245,17 +1347,9 @@ fn rejected_record_observation( state: SccmSiteCoreState::ParseGap, finding_class: SccmFindingClass::Symptom, confidence: SccmSiteCoreConfidence::Low, - evidence: vec![SccmSiteCoreEvidence { - artifact_id: evidence.reference.artifact_id.clone(), - entry_id: evidence.reference.entry_id.clone(), - line_start, - line_end, - terminal: None, - recovery: None, - complete_logical_record: Some(true), - }], + evidence: retained_evidence.into_iter().collect(), coverage_gap_artifact_ids: Vec::new(), - next_artifacts: vec![request], + next_artifacts: request.into_iter().collect(), } } @@ -1263,6 +1357,7 @@ fn coverage_request( gap: &SccmSiteCoreCoverageGap, context: &SiteCoreContext<'_>, ) -> Option { + let group = SiteCoreGroup::from_source_id(&gap.source_id)?; 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) { @@ -1271,9 +1366,11 @@ fn coverage_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)); } - SiteCoreGroup::from_source_id(&gap.source_id) - .map(|group| group_request(group, &gap.reason_code, None)) + let scope = request_scope_for_gap(gap, context)?; + Some(group_request(group, &gap.reason_code, scope)) } fn complete_source_request( @@ -1281,6 +1378,7 @@ fn complete_source_request( 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, @@ -1288,19 +1386,14 @@ fn complete_source_request( candidates: vec![candidate], max_artifacts: 1, max_bytes_per_artifact: None, - scope: SccmSiteCoreRequestScope { - producer_host_handle: artifact.producer_host_handle.clone(), - component_id: None, - work_item_id: None, - rotation_lineage_handle: Some(artifact.rotation_lineage_handle.clone()), - }, + scope, }) } fn group_request( group: SiteCoreGroup, reason_code: &str, - producer_host_handle: Option, + scope: SccmSiteCoreRequestScope, ) -> SccmSiteCoreArtifactRequest { let stem = match group { SiteCoreGroup::Component => "sitecomp", @@ -1322,15 +1415,107 @@ fn group_request( ], max_artifacts: MAX_SITE_CORE_REQUEST_ARTIFACTS, max_bytes_per_artifact: None, - scope: SccmSiteCoreRequestScope { - producer_host_handle, - component_id: None, - work_item_id: None, - rotation_lineage_handle: 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) + .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 { diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index 1d49b6238..c8db6f90e 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -346,6 +346,17 @@ fn assert_explicit_gap_and_request( .find(|request| request.logical_name == source_id) .expect("coverage gap has a source-specific artifact request"); assert_bounded_request_has_specific_scope(request); + for request in &analysis.artifact_requests { + assert_bounded_request_has_specific_scope(request); + } +} + +fn assert_gap_reason(analysis: &SccmSiteCoreAnalysis, artifact_id: &str, reason_code: &str) { + assert!(analysis.coverage_gaps.iter().any(|gap| { + gap.artifact_id == artifact_id + && gap.state == SccmCoverageState::ParseFailed + && gap.reason_code == reason_code + })); } fn assert_delimiter_attached_unknown_label_fails_closed(delimiter: char) { @@ -940,6 +951,14 @@ fn rejected_evidence_contracts_become_source_gaps_with_scoped_requests() { wrong_role.evidence[0].role = SccmRole::ManagementPoint; let analysis = analyze_site_core(&wrong_role); assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); + assert_gap_reason(&analysis, "sitecomp-current", "evidence-role-rejected"); + assert!(analysis.unlinked_observations.iter().any(|observation| { + observation.finding_class == SccmFindingClass::Symptom + && observation + .evidence + .iter() + .any(|evidence| evidence.entry_id == wrong_role.evidence[0].evidence_id) + })); assert_eq!(analysis.results.len(), 1); assert!(analysis.results.iter().all(|result| { result.state != SccmSiteCoreState::Healthy @@ -950,6 +969,10 @@ fn rejected_evidence_contracts_become_source_gaps_with_scoped_requests() { incomplete_reference.evidence[0].reference.line_end = None; let analysis = analyze_site_core(&incomplete_reference); assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); + assert_gap_reason(&analysis, "sitecomp-current", "evidence-reference-rejected"); + assert!(analysis.unlinked_observations.iter().any(|observation| { + observation.finding_class == SccmFindingClass::Symptom && observation.evidence.is_empty() + })); assert_eq!(analysis.results.len(), 1); assert!(analysis.results.iter().all(|result| { result.state != SccmSiteCoreState::Healthy @@ -958,8 +981,22 @@ fn rejected_evidence_contracts_become_source_gaps_with_scoped_requests() { 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); let analysis = analyze_site_core(&cross_source_reference); assert_explicit_gap_and_request(&analysis, "z-site-status", "server-status"); + assert_gap_reason( + &analysis, + "z-site-status", + "evidence-source-attribution-rejected", + ); + assert!(analysis.unlinked_observations.iter().any(|observation| { + observation.finding_class == SccmFindingClass::Symptom + && observation + .evidence + .iter() + .any(|evidence| evidence.entry_id == cross_source_reference.evidence[0].evidence_id) + })); assert_eq!(analysis.results.len(), 1); assert!(analysis.results.iter().all(|result| { result.state != SccmSiteCoreState::Healthy @@ -970,6 +1007,11 @@ fn rejected_evidence_contracts_become_source_gaps_with_scoped_requests() { unresolved_reference.evidence[0].reference.artifact_id = "orphan-sitecomp-record".to_owned(); let analysis = analyze_site_core(&unresolved_reference); assert_explicit_gap_and_request(&analysis, "orphan-sitecomp-record", "server-sitecomp"); + assert_gap_reason( + &analysis, + "orphan-sitecomp-record", + "evidence-source-unresolved", + ); assert_eq!(analysis.results.len(), 1); assert!(analysis.results.iter().all(|result| { result.state != SccmSiteCoreState::Healthy From be8f9f5558da5e233aceb3b2e046914ac97b2982 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 05:31:13 -0400 Subject: [PATCH 287/422] test(sccm): expose foreign source scope borrowing --- .../tests/sccm_server_site_core.rs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index c8db6f90e..89d481221 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -1019,6 +1019,68 @@ fn rejected_evidence_contracts_become_source_gaps_with_scoped_requests() { })); } +#[test] +fn foreign_artifact_identity_cannot_scope_an_unresolved_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); + let gap = analysis + .coverage_gaps + .iter() + .find(|gap| { + gap.source_id == "server-sitecomp" && gap.reason_code == "evidence-source-unresolved" + }) + .expect("foreign attribution becomes a site-core coverage gap"); + assert_ne!(gap.artifact_id, "foreign-artifact"); + assert!(gap + .artifact_id + .starts_with("site-core:rejected-artifact:v1:")); + let request = analysis + .artifact_requests + .iter() + .find(|request| request.logical_name == "server-sitecomp") + .expect("unresolved site-core evidence has a bounded request"); + assert_bounded_request_has_specific_scope(request); + assert_eq!( + request.scope.producer_host_handle.as_deref(), + Some("synthetic:host:site-01") + ); + assert_ne!( + request.scope.rotation_lineage_handle.as_deref(), + Some("foreign-lineage") + ); +} + +#[test] +fn rejected_nonprofile_prose_is_coverage_not_a_profile_symptom() { + 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; + + let analysis = analyze_site_core(&assessment); + assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); + assert_gap_reason(&analysis, "sitecomp-current", "evidence-role-rejected"); + assert_eq!(analysis.unlinked_observations.len(), 1); + assert_eq!( + analysis.unlinked_observations[0].finding_class, + SccmFindingClass::InsufficientEvidence + ); + assert!(analysis.unlinked_observations[0].evidence.is_empty()); +} + #[test] fn colliding_evidence_identities_are_parse_gaps_not_silent_drops() { let mut assessment = assess(&[Source::sitecomp(HEALTHY_SITECOMP), Source::absent_status()]); From 142d3aafc3f85d39f18b3ee4cf7b321a02b8f2ae Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 05:31:50 -0400 Subject: [PATCH 288/422] fix(sccm): isolate site core request attribution --- .../src/sccm/server/windows/site_core.rs | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs index de0a1fe27..f5bad8120 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs @@ -335,7 +335,13 @@ pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAna continue; }; if let Some(reason_code) = evidence_record_rejection_reason(evidence, source.group) { - record_observations.push(rejected_record_observation(evidence, source, reason_code)); + 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] { @@ -483,7 +489,9 @@ 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()) @@ -513,7 +521,10 @@ fn evidence_source_rejections( continue; }; unresolved_gaps.push(SccmSiteCoreCoverageGap { - artifact_id: safe_coverage_artifact_id(evidence), + 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(), @@ -550,8 +561,8 @@ fn evidence_component_group(component: Option<&str>) -> Option { } } -fn safe_coverage_artifact_id(evidence: &SccmEvidence) -> String { - if safe_site_core_opaque_id(&evidence.reference.artifact_id) { +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( @@ -1426,7 +1437,9 @@ fn request_scope_for_gap( let exact_artifacts = context .artifacts .iter() - .filter(|artifact| artifact.artifact_id == gap.artifact_id) + .filter(|artifact| { + artifact.artifact_id == gap.artifact_id && artifact.source_id == gap.source_id + }) .collect::>(); let artifacts = if exact_artifacts.is_empty() { context From 3a5297351c62a852efb33ebe7e51229266a76757 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 05:41:45 -0400 Subject: [PATCH 289/422] test(sccm): expose undeclared status coverage gap --- .../tests/sccm_server_site_core.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index 89d481221..16ba8d976 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -830,6 +830,66 @@ fn incomplete_sources_are_coverage_states_not_role_health_claims() { .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_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 site_core_output_is_byte_identical_after_assessment_reordering() { let assessment = assess(&[ From 1ea4e23c3ecad9c861f670c55177ea203b088b6a Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 05:43:48 -0400 Subject: [PATCH 290/422] fix(sccm): materialize missing status coverage --- .../src/sccm/server/windows/site_core.rs | 73 +++++++++++++++++-- .../tests/sccm_server_site_core.rs | 18 ++++- 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs index f5bad8120..2661d9003 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs @@ -298,6 +298,7 @@ struct SiteCoreContext<'a> { sources: BTreeMap<&'a str, AdmittedSource<'a>>, evidence_identity_is_unique: Vec, coverage_gaps: Vec, + coverage_gap_producer_hosts: BTreeMap, } impl<'a> SiteCoreContext<'a> { @@ -322,12 +323,55 @@ impl<'a> SiteCoreContext<'a> { sources, evidence_identity_is_unique, coverage_gaps, + coverage_gap_producer_hosts: BTreeMap::new(), } } + + fn add_undeclared_status_gaps( + &mut self, + grouped: &BTreeMap>, + ) { + let producer_hosts = grouped + .iter() + .filter(|(_, facts)| { + facts + .iter() + .any(|fact| fact.marker.group == SiteCoreGroup::Component) + }) + .map(|(key, _)| key.producer_host_handle.clone()) + .collect::>(); + for producer_host_handle in producer_hosts { + let compatible_status_source_exists = self.sources.values().any(|source| { + source.group == SiteCoreGroup::Status + && 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() + }); + if compatible_status_source_exists { + continue; + } + let artifact_id = stable_opaque_id( + "site-core:missing-source:v1:", + &[SCCM_SITE_CORE_STATUS_GROUP, &producer_host_handle], + ); + self.coverage_gap_producer_hosts + .insert(artifact_id.clone(), producer_host_handle); + self.coverage_gaps.push(SccmSiteCoreCoverageGap { + artifact_id, + source_id: SCCM_SITE_CORE_STATUS_GROUP.to_owned(), + state: SccmCoverageState::Absent, + reason_code: "required-status-source-not-declared".to_owned(), + diagnostic_meaning: SccmSiteCoreDiagnosticMeaning::CoverageOnly, + }); + } + sort_and_dedup_coverage_gaps(&mut self.coverage_gaps); + } } pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAnalysis { - let context = SiteCoreContext::new(intake); + let mut context = SiteCoreContext::new(intake); let mut grouped = BTreeMap::>::new(); let mut record_observations = Vec::new(); for (position, evidence) in intake.evidence.iter().enumerate() { @@ -361,6 +405,7 @@ pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAna ProfileRecordParse::NotCandidate => {} } } + context.add_undeclared_status_gaps(&grouped); let mut results = Vec::new(); let mut findings = Vec::new(); @@ -663,12 +708,16 @@ fn coverage_gap_ids_for_key( .iter() .filter(|gap| { 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()) - }) + .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() @@ -1369,6 +1418,16 @@ fn coverage_request( 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) { diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index 16ba8d976..6c6372c2c 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -671,9 +671,21 @@ fn unrelated_same_minute_components_and_producer_hosts_never_merge() { .producer_host_handle = Some("synthetic:host:site-02".to_owned()); let foreign_gap_analysis = analyze_site_core(&foreign_gap); assert_eq!(foreign_gap_analysis.results.len(), 1); - assert!(foreign_gap_analysis.results[0] - .coverage_gap_artifact_ids - .is_empty()); + 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 From 7fdc1c637a9d29ec465d679e3e475265732dd3b6 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 05:46:48 -0400 Subject: [PATCH 291/422] feat(sccm): read native client manifests (#319) --- src-tauri/src/lib.rs | 2 + src-tauri/src/sccm/contract.rs | 572 +++++++++++++++++++ src-tauri/src/sccm/manifest.rs | 911 +++++++++++++++++++++++++++++++ src-tauri/src/sccm/mod.rs | 13 + src-tauri/src/sccm/private_fs.rs | 349 ++++++++++++ 5 files changed, 1847 insertions(+) create mode 100644 src-tauri/src/sccm/contract.rs create mode 100644 src-tauri/src/sccm/manifest.rs create mode 100644 src-tauri/src/sccm/mod.rs create mode 100644 src-tauri/src/sccm/private_fs.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ddc3687d4..cf001bbf6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,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; 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/manifest.rs b/src-tauri/src/sccm/manifest.rs new file mode 100644 index 000000000..ecfab07c5 --- /dev/null +++ b/src-tauri/src/sccm/manifest.rs @@ -0,0 +1,911 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, 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, open_file_no_follow, verify_bundle_root}; + +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_GROUP: &str = "client-ccmsetup"; +const LEGACY_CCMSETUP_BASENAME: &str = "ccmsetup.log"; +const MAX_SAFE_TEXT_CHARS: usize = 160; + +pub fn read_sccm_manifest_or_legacy(bundle_root: &Path) -> Result { + let canonical_root = verify_bundle_root(bundle_root)?; + let manifest_path = canonical_root.join(SCCM_MANIFEST_FILE_NAME); + match open_file_no_follow(&manifest_path) { + 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(), + } + })?; + validate_native_manifest(&canonical_root, &manifest)?; + Ok(manifest) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + read_legacy_manifest(&canonical_root) + } + Err(_) => Err(AppError::InvalidInput( + "SCCM manifest cannot be opened safely".to_owned(), + )), + } +} + +pub fn manifest_to_client_intake_bundle( + manifest: &SccmBundleManifestV1, +) -> Result { + 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), + }) + .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 { + let manifest = read_sccm_manifest_or_legacy(bundle_root)?; + if manifest.provenance == SccmManifestProvenance::LegacyGenericUnscoped { + let canonical_root = verify_bundle_root(bundle_root)?; + return read_legacy_client_intake_bundle(&canonical_root); + } + manifest_to_client_intake_bundle(&manifest) +} + +fn validate_native_manifest( + bundle_root: &Path, + manifest: &SccmBundleManifestV1, +) -> Result<(), AppError> { + validate_native_manifest_structure(manifest)?; + manifest_to_client_intake_bundle(manifest)?; + for artifact in &manifest.artifacts { + if artifact.state.is_physical() { + validate_evidence_file(bundle_root, artifact)?; + } + } + 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: &Path, + 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 candidate = bundle_root.join(relative_path); + let metadata = fs::symlink_metadata(&candidate) + .map_err(|_| AppError::InvalidInput("SCCM evidence file is unavailable".to_owned()))?; + if is_reparse_point(&metadata) || !metadata.is_file() { + return Err(AppError::InvalidInput( + "SCCM evidence must be a real file".to_owned(), + )); + } + let canonical = candidate.canonicalize().map_err(|_| { + AppError::InvalidInput("SCCM evidence cannot be resolved safely".to_owned()) + })?; + if !canonical.starts_with(bundle_root) || metadata.len() != artifact.bytes_copied { + return Err(AppError::InvalidInput( + "SCCM evidence violates path or size coherence".to_owned(), + )); + } + let mut input = open_file_no_follow(&candidate) + .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: &Path) -> 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: &Path) -> Result { + let legacy_path = bundle_root.join(LEGACY_MANIFEST_FILE_NAME); + let input = open_file_no_follow(&legacy_path).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: &Path, +) -> 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_GROUP); + 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), + }); + } + 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, + Some("Failed") | Some(_) | None => 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, + Some("collected") | Some("failed") | Some(_) | None => { + 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()) +} diff --git a/src-tauri/src/sccm/mod.rs b/src-tauri/src/sccm/mod.rs new file mode 100644 index 000000000..9cd43a25a --- /dev/null +++ b/src-tauri/src/sccm/mod.rs @@ -0,0 +1,13 @@ +//! 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. + +mod contract; +mod manifest; +mod private_fs; + +pub use cmtraceopen_parser::sccm::{SccmCoverageState, SccmRole, SccmRotation}; +pub use contract::*; +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..8460bb26d --- /dev/null +++ b/src-tauri/src/sccm/private_fs.rs @@ -0,0 +1,349 @@ +use std::fs::{self, File, OpenOptions}; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::error::AppError; + +pub(super) fn verify_bundle_root(bundle_root: &Path) -> Result { + let metadata = fs::symlink_metadata(bundle_root).map_err(|_| { + AppError::InvalidInput("SCCM bundle root is unavailable or unsafe".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)?; + bundle_root.canonicalize().map_err(|_| { + AppError::InvalidInput("SCCM bundle root cannot be resolved safely".to_owned()) + }) +} + +#[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(path: &Path, _metadata: &fs::Metadata) -> Result<(), AppError> { + use std::os::windows::ffi::OsStrExt; + + use windows::core::PCWSTR; + use windows::Win32::Foundation::{CloseHandle, LocalFree, ERROR_SUCCESS, HANDLE, HLOCAL}; + use windows::Win32::Security::Authorization::{ + GetExplicitEntriesFromAclW, GetNamedSecurityInfoW, 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 path_wide = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let mut owner = PSID::default(); + let mut dacl: *mut ACL = std::ptr::null_mut(); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + let status = unsafe { + GetNamedSecurityInfoW( + PCWSTR(path_wide.as_ptr()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + Some(&mut owner), + None, + Some(&mut dacl), + None, + &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(not(any(unix, windows)))] +fn verify_private_directory(_path: &Path, _metadata: &fs::Metadata) -> Result<(), AppError> { + Ok(()) +} + +#[cfg(unix)] +pub(super) fn open_file_no_follow(path: &Path) -> io::Result { + use std::os::fd::AsRawFd; + 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)?; + 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(file) +} + +#[cfg(windows)] +pub(super) fn open_file_no_follow(path: &Path) -> io::Result { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let file = OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)?; + require_regular_file(file) +} + +#[cfg(not(any(unix, windows)))] +pub(super) fn open_file_no_follow(path: &Path) -> io::Result { + let file = OpenOptions::new().read(true).open(path)?; + require_regular_file(file) +} + +fn require_regular_file(file: File) -> io::Result { + 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", + )); + } + Ok(file) +} + +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))] +mod tests { + use std::os::fd::AsRawFd; + + 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"); + } +} From 849df5ab527aedbe9d53cd178808780d14452496 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 06:01:17 -0400 Subject: [PATCH 292/422] test(sccm): expose undeclared component coverage --- .../tests/sccm_server_site_core.rs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index 6c6372c2c..a99375de8 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -902,6 +902,142 @@ fn undeclared_status_source_is_an_explicit_host_scoped_coverage_gap() { } } +#[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") + .collect::>(); + assert_eq!(component_requests.len(), 1); + let request = component_requests[0]; + assert_eq!( + request.reason_code, + "required-component-source-not-declared" + ); + 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 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 mut assessment = assess(&[ + Source::status(HEALTHY_STATUS), + Source::sitecomp(HEALTHY_SITECOMP), + ]); + assessment + .artifacts + .iter_mut() + .find(|artifact| artifact.source_id == "server-sitecomp") + .expect("component artifact") + .producer_host_handle = Some("synthetic:host:site-02".to_owned()); + + 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:site-02") + .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(&[ From 8999216cc73f5963aa9b5b101a2cf0312c113eb7 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 06:03:33 -0400 Subject: [PATCH 293/422] fix(sccm): materialize missing component coverage --- .../src/sccm/server/windows/site_core.rs | 129 +++++++++++------- .../tests/sccm_server_site_core.rs | 28 +++- 2 files changed, 98 insertions(+), 59 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs index 2661d9003..54cebd692 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs @@ -327,44 +327,53 @@ impl<'a> SiteCoreContext<'a> { } } - fn add_undeclared_status_gaps( + fn add_undeclared_peer_source_gaps( &mut self, grouped: &BTreeMap>, ) { - let producer_hosts = grouped - .iter() - .filter(|(_, facts)| { - facts - .iter() - .any(|fact| fact.marker.group == SiteCoreGroup::Component) - }) - .map(|(key, _)| key.producer_host_handle.clone()) - .collect::>(); - for producer_host_handle in producer_hosts { - let compatible_status_source_exists = self.sources.values().any(|source| { - source.group == SiteCoreGroup::Status - && 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() - }); - if compatible_status_source_exists { - continue; + 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() + }); + 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, + }); } - let artifact_id = stable_opaque_id( - "site-core:missing-source:v1:", - &[SCCM_SITE_CORE_STATUS_GROUP, &producer_host_handle], - ); - self.coverage_gap_producer_hosts - .insert(artifact_id.clone(), producer_host_handle); - self.coverage_gaps.push(SccmSiteCoreCoverageGap { - artifact_id, - source_id: SCCM_SITE_CORE_STATUS_GROUP.to_owned(), - state: SccmCoverageState::Absent, - reason_code: "required-status-source-not-declared".to_owned(), - diagnostic_meaning: SccmSiteCoreDiagnosticMeaning::CoverageOnly, - }); } sort_and_dedup_coverage_gaps(&mut self.coverage_gaps); } @@ -405,7 +414,7 @@ pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAna ProfileRecordParse::NotCandidate => {} } } - context.add_undeclared_status_gaps(&grouped); + context.add_undeclared_peer_source_gaps(&grouped); let mut results = Vec::new(); let mut findings = Vec::new(); @@ -1290,6 +1299,18 @@ fn next_artifacts_for_state( { 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() } @@ -1297,29 +1318,31 @@ fn status_request( reason_code: &str, key: &SccmSiteCoreTransactionKey, ) -> SccmSiteCoreArtifactRequest { - SccmSiteCoreArtifactRequest { - logical_name: SCCM_SITE_CORE_STATUS_GROUP.to_owned(), - role: SccmRole::SiteServer, - reason_code: reason_code.to_owned(), - candidates: vec![ - SccmSiteCoreArtifactCandidate { - basename: "statmgr.log".to_owned(), - rotation: "current".to_owned(), - }, - SccmSiteCoreArtifactCandidate { - basename: "statmgr.lo_".to_owned(), - rotation: "loUnderscore".to_owned(), - }, - ], - max_artifacts: MAX_SITE_CORE_REQUEST_ARTIFACTS, - max_bytes_per_artifact: None, - scope: SccmSiteCoreRequestScope { + 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( diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index a99375de8..90c221ab2 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -860,6 +860,7 @@ fn undeclared_status_source_is_an_explicit_host_scoped_coverage_gap() { 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]; @@ -951,14 +952,16 @@ fn undeclared_component_source_is_an_explicit_host_component_work_item_scoped_co let component_requests = analysis .artifact_requests .iter() - .filter(|request| request.logical_name == "server-sitecomp") + .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, - "required-component-source-not-declared" - ); + assert_eq!(request.reason_code, "matching-component-evidence-missing"); assert_eq!(request.max_artifacts, 2); assert_eq!( request @@ -966,7 +969,10 @@ fn undeclared_component_source_is_an_explicit_host_component_work_item_scoped_co .iter() .map(|candidate| (candidate.basename.as_str(), candidate.rotation.as_str())) .collect::>(), - vec![("sitecomp.log", "current"), ("sitecomp.lo_", "loUnderscore")] + vec![ + ("sitecomp.log", "current"), + ("sitecomp.lo_", "loUnderscore") + ] ); assert_eq!( request.scope.producer_host_handle.as_deref(), @@ -977,6 +983,16 @@ fn undeclared_component_source_is_an_explicit_host_component_work_item_scoped_co assert_eq!(request.scope.rotation_lineage_handle, None); } +#[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)]); From 8294729a9c9fc58fd81e8a64c7200fc1810e73a2 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 06:07:34 -0400 Subject: [PATCH 294/422] test(sccm): expose native manifest physical cap gaps --- src-tauri/tests/sccm_client_manifest.rs | 87 +++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src-tauri/tests/sccm_client_manifest.rs b/src-tauri/tests/sccm_client_manifest.rs index 26e01b4dd..a61f70009 100644 --- a/src-tauri/tests/sccm_client_manifest.rs +++ b/src-tauri/tests/sccm_client_manifest.rs @@ -206,6 +206,21 @@ fn write_native_bundle(root: &Path, artifacts: &[Value], capture_gaps: &[Value]) .expect("write fixture 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"); @@ -474,3 +489,75 @@ fn malformed_native_state_is_rejected_before_pure_projection() { 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, &[current, rotated], &[]); + set_native_limits(&bundle_root, 1, 4096); + + 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, std::slice::from_ref(¤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, &[current, 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_overflowing_physical_byte_totals_before_hashing_evidence() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let mut current = physical_artifact(SccmRotation::Current, b"policy-current"); + current["bytesCopied"] = json!(u64::MAX); + let rotated = physical_artifact(SccmRotation::Numbered(1), b"policy-rotation-one"); + write_native_bundle(&bundle_root, &[current, rotated], &[]); + set_native_limits(&bundle_root, 2, u64::MAX); + + let error = read_sccm_manifest_or_legacy(&bundle_root) + .expect_err("overflowing source bytes fail before unbounded evidence verification"); + assert!(error.to_string().contains("source 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, std::slice::from_ref(¤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("single-link")); +} From eecdb30f6878eb58e78ebb962a5e708f33f89fca Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 06:15:02 -0400 Subject: [PATCH 295/422] test(sccm): expose malformed peer coverage loss --- .../tests/sccm_server_site_core.rs | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index 90c221ab2..18f92ffbb 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -292,6 +292,31 @@ fn assess(sources: &[Source<'_>]) -> SccmServerIntakeAssessment { .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 assert_bounded_request_has_specific_scope(request: &SccmSiteCoreArtifactRequest) { assert!((1..=2).contains(&request.max_artifacts)); assert!(!request.candidates.is_empty()); @@ -325,6 +350,114 @@ fn assert_bounded_request_has_specific_scope(request: &SccmSiteCoreArtifactReque ); } +fn assert_malformed_peer_source_fails_closed( + analysis: &SccmSiteCoreAnalysis, + malformed_id: &str, + required_source_id: &str, + required_reason_code: &str, +) { + 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()); + + let synthetic_gap = analysis + .coverage_gaps + .iter() + .find(|gap| { + gap.source_id == required_source_id + && gap.state == SccmCoverageState::Absent + && gap.reason_code == required_reason_code + }) + .expect("ineligible peer leaves a synthetic missing-source gap"); + assert!(synthetic_gap + .artifact_id + .starts_with("site-core:missing-source:v1:")); + assert_eq!( + result.coverage_gap_artifact_ids, + vec![synthetic_gap.artifact_id.clone()] + ); + + let rejected_gap = analysis + .coverage_gaps + .iter() + .find(|gap| { + gap.source_id == required_source_id + && gap.state == SccmCoverageState::ParseFailed + && gap.reason_code == "evidence-reference-rejected" + }) + .expect("malformed peer remains explicit rejected coverage"); + assert!(rejected_gap + .artifact_id + .starts_with("site-core:rejected-artifact:v1:")); + assert_ne!(rejected_gap.artifact_id, malformed_id); + assert!(analysis.coverage_gaps.iter().all(|gap| { + gap.artifact_id != malformed_id + && !gap.artifact_id.is_empty() + && gap.artifact_id.len() <= 256 + && gap.artifact_id.trim() == gap.artifact_id + && gap.artifact_id.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b':' | b'_' | b'-') + }) + })); + + let result_finding = analysis + .findings + .iter() + .find(|finding| finding.subject_id == result.result_id) + .expect("malformed peer retains a validated result 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, + synthetic_gap.artifact_id + ); + + for gap in &analysis.coverage_gaps { + let observation = analysis + .unlinked_observations + .iter() + .find(|observation| observation.coverage_gap_artifact_ids == [gap.artifact_id.clone()]) + .expect("each gap has an explicit coverage observation"); + let finding = analysis + .findings + .iter() + .find(|finding| finding.subject_id == observation.observation_id) + .expect("each gap has a validated coverage finding"); + assert_eq!( + finding.finding.class, + SccmFindingClass::InsufficientEvidence + ); + assert!(finding + .finding + .coverage_gaps + .iter() + .any(|finding_gap| finding_gap.artifact_id == gap.artifact_id)); + } + + let requests = analysis + .artifact_requests + .iter() + .filter(|request| request.logical_name == required_source_id) + .collect::>(); + assert!(!requests.is_empty()); + for request in requests { + assert_bounded_request_has_specific_scope(request); + assert_eq!( + request.scope.producer_host_handle.as_deref(), + Some("synthetic:host:site-01") + ); + } + assert!(!analysis.cross_side_correlation_performed); +} + fn assert_explicit_gap_and_request( analysis: &SccmSiteCoreAnalysis, artifact_id: &str, @@ -983,6 +1116,42 @@ fn undeclared_component_source_is_an_explicit_host_component_work_item_scoped_co 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, + "server-status", + "required-status-source-not-declared", + ); + } +} + +#[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, + "server-sitecomp", + "required-component-source-not-declared", + ); + } +} + #[test] fn undeclared_component_gap_requires_admitted_status_facts() { let unrelated_status = HEALTHY_STATUS.replace("profileId=sccm-site-core", "profileId=other"); From 607c732bb1d6be9dea4a0301c3cc7d4aebce2238 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 06:12:50 -0400 Subject: [PATCH 296/422] fix(sccm): bind native manifest evidence to verified roots --- src-tauri/src/sccm/manifest.rs | 108 +++++---- src-tauri/src/sccm/private_fs.rs | 285 ++++++++++++++++++++++-- src-tauri/tests/sccm_client_manifest.rs | 37 +-- 3 files changed, 363 insertions(+), 67 deletions(-) diff --git a/src-tauri/src/sccm/manifest.rs b/src-tauri/src/sccm/manifest.rs index ecfab07c5..8f0a9c942 100644 --- a/src-tauri/src/sccm/manifest.rs +++ b/src-tauri/src/sccm/manifest.rs @@ -1,5 +1,5 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::fs::{self, File}; +use std::fs::File; use std::io::Read; use std::path::{Component, Path}; @@ -23,7 +23,7 @@ use super::contract::{ 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, open_file_no_follow, verify_bundle_root}; +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"; @@ -35,9 +35,8 @@ const LEGACY_CCMSETUP_BASENAME: &str = "ccmsetup.log"; const MAX_SAFE_TEXT_CHARS: usize = 160; pub fn read_sccm_manifest_or_legacy(bundle_root: &Path) -> Result { - let canonical_root = verify_bundle_root(bundle_root)?; - let manifest_path = canonical_root.join(SCCM_MANIFEST_FILE_NAME); - match open_file_no_follow(&manifest_path) { + 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 = @@ -47,11 +46,11 @@ pub fn read_sccm_manifest_or_legacy(bundle_root: &Path) -> Result { - read_legacy_manifest(&canonical_root) + read_legacy_manifest(&verified_root) } Err(_) => Err(AppError::InvalidInput( "SCCM manifest cannot be opened safely".to_owned(), @@ -59,7 +58,7 @@ pub fn read_sccm_manifest_or_legacy(bundle_root: &Path) -> Result Result { validate_native_manifest_structure(manifest)?; @@ -114,18 +113,19 @@ pub fn read_sccm_client_intake_bundle( ) -> Result { let manifest = read_sccm_manifest_or_legacy(bundle_root)?; if manifest.provenance == SccmManifestProvenance::LegacyGenericUnscoped { - let canonical_root = verify_bundle_root(bundle_root)?; - return read_legacy_client_intake_bundle(&canonical_root); + let verified_root = verify_bundle_root(bundle_root)?; + return read_legacy_client_intake_bundle(&verified_root); } manifest_to_client_intake_bundle(&manifest) } fn validate_native_manifest( - bundle_root: &Path, + bundle_root: &VerifiedBundleRoot, manifest: &SccmBundleManifestV1, ) -> Result<(), AppError> { validate_native_manifest_structure(manifest)?; 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)?; @@ -134,6 +134,46 @@ fn validate_native_manifest( Ok(()) } +fn validate_physical_source_limits(manifest: &SccmBundleManifestV1) -> Result<(), AppError> { + let mut totals = BTreeMap::::new(); + for artifact in manifest + .artifacts + .iter() + .filter(|artifact| artifact.state.is_physical()) + { + 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 validate_native_manifest_structure(manifest: &SccmBundleManifestV1) -> Result<(), AppError> { if manifest.sccm_manifest_version != SCCM_MANIFEST_VERSION || manifest.diagnostics_schema_version != SCCM_DIAGNOSTICS_SCHEMA_VERSION @@ -534,30 +574,15 @@ fn validate_relative_path( } fn validate_evidence_file( - bundle_root: &Path, + 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 candidate = bundle_root.join(relative_path); - let metadata = fs::symlink_metadata(&candidate) - .map_err(|_| AppError::InvalidInput("SCCM evidence file is unavailable".to_owned()))?; - if is_reparse_point(&metadata) || !metadata.is_file() { - return Err(AppError::InvalidInput( - "SCCM evidence must be a real file".to_owned(), - )); - } - let canonical = candidate.canonicalize().map_err(|_| { - AppError::InvalidInput("SCCM evidence cannot be resolved safely".to_owned()) - })?; - if !canonical.starts_with(bundle_root) || metadata.len() != artifact.bytes_copied { - return Err(AppError::InvalidInput( - "SCCM evidence violates path or size coherence".to_owned(), - )); - } - let mut input = open_file_no_follow(&candidate) + 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()) @@ -640,7 +665,9 @@ fn sha256_exact_file(input: &mut File, expected_bytes: u64) -> Result Result { +fn read_legacy_manifest( + bundle_root: &VerifiedBundleRoot, +) -> Result { let legacy = read_legacy_value(bundle_root)?; let values = legacy .get("artifacts") @@ -680,17 +707,18 @@ fn read_legacy_manifest(bundle_root: &Path) -> Result Result { - let legacy_path = bundle_root.join(LEGACY_MANIFEST_FILE_NAME); - let input = open_file_no_follow(&legacy_path).map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - AppError::InvalidInput(format!( +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()) - } - })?; + } 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(), @@ -709,7 +737,7 @@ fn legacy_gaps(legacy: &Value) -> Result<&[Value], AppError> { } fn read_legacy_client_intake_bundle( - bundle_root: &Path, + bundle_root: &VerifiedBundleRoot, ) -> Result { let legacy = read_legacy_value(bundle_root)?; let supported_profile = legacy diff --git a/src-tauri/src/sccm/private_fs.rs b/src-tauri/src/sccm/private_fs.rs index 8460bb26d..3ae7252f0 100644 --- a/src-tauri/src/sccm/private_fs.rs +++ b/src-tauri/src/sccm/private_fs.rs @@ -1,22 +1,71 @@ use std::fs::{self, File, OpenOptions}; use std::io; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path}; use crate::error::AppError; -pub(super) fn verify_bundle_root(bundle_root: &Path) -> Result { - let metadata = fs::symlink_metadata(bundle_root).map_err(|_| { - AppError::InvalidInput("SCCM bundle root is unavailable or unsafe".to_owned()) - })?; - if is_reparse_point(&metadata) || !metadata.is_dir() { - return Err(AppError::InvalidInput( - "SCCM bundle root must be a real directory".to_owned(), - )); +/// 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 rejects the native boundary rather than making a weaker +/// pathname-based safety claim until it has equivalent handle-relative traversal. +pub(super) struct VerifiedBundleRoot { + #[cfg(unix)] + 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(not(unix))] + { + 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(not(unix))] + { + let _ = relative; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "handle-bound directory traversal is unavailable", + )) + } } - verify_private_directory(bundle_root, &metadata)?; - bundle_root.canonicalize().map_err(|_| { - AppError::InvalidInput("SCCM bundle root cannot be resolved safely".to_owned()) - }) } #[cfg(unix)] @@ -258,7 +307,7 @@ fn verify_private_directory(_path: &Path, _metadata: &fs::Metadata) -> Result<() Ok(()) } -#[cfg(unix)] +#[cfg(all(unix, test))] pub(super) fn open_file_no_follow(path: &Path) -> io::Result { use std::os::fd::AsRawFd; use std::os::unix::fs::OpenOptionsExt; @@ -281,6 +330,73 @@ pub(super) fn open_file_no_follow(path: &Path) -> io::Result { Ok(file) } +#[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 { + return require_regular_file(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)] pub(super) fn open_file_no_follow(path: &Path) -> io::Result { use std::os::windows::fs::OpenOptionsExt; @@ -307,9 +423,55 @@ fn require_regular_file(file: File) -> io::Result { "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 require_single_link(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::new(io::ErrorKind::Other, error.to_string()))?; + 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; @@ -325,9 +487,35 @@ pub(super) fn is_reparse_point(metadata: &fs::Metadata) -> bool { 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, 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, unix))] mod tests { + use std::cell::RefCell; + use std::io::Read; use std::os::fd::AsRawFd; + use std::rc::Rc; use tempfile::tempdir; @@ -346,4 +534,73 @@ mod tests { open_file_no_follow(root.path()).expect_err("directories are rejected after opening"); } + + #[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); + set_open_component_hook(Some(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"); + set_open_component_hook(None); + 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"); + } } diff --git a/src-tauri/tests/sccm_client_manifest.rs b/src-tauri/tests/sccm_client_manifest.rs index a61f70009..3190fbadb 100644 --- a/src-tauri/tests/sccm_client_manifest.rs +++ b/src-tauri/tests/sccm_client_manifest.rs @@ -2,9 +2,9 @@ use std::fs; use std::path::Path; use app_lib::sccm::{ - manifest_to_client_intake_bundle, read_sccm_client_intake_bundle, read_sccm_manifest_or_legacy, - SccmBundleManifestV1, SccmManifestProvenance, SccmManifestSourceState, - MAX_SCCM_MANIFEST_ARTIFACTS, SCCM_MANIFEST_FILE_NAME, + 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::{assess_client_intake, SccmCoverageState, SccmRotation}; use serde_json::{json, Value}; @@ -229,7 +229,7 @@ fn validated_v1_reader_projects_one_physical_client_artifact() { write_native_bundle(&bundle_root, std::slice::from_ref(¤t), &[]); let manifest = read_sccm_manifest_or_legacy(&bundle_root).expect("validated manifest"); - let bundle = manifest_to_client_intake_bundle(&manifest).expect("pure projection"); + let bundle = read_sccm_client_intake_bundle(&bundle_root).expect("verified pure projection"); assert_eq!(manifest.sccm_manifest_version, 1); assert_eq!( @@ -286,13 +286,16 @@ fn omitted_capped_rotation_projects_as_a_gap_without_a_fake_fragment() { #[test] fn parse_failed_omitted_rotation_remains_coverage_only() { - let manifest_value = native_manifest( - vec![physical_artifact(SccmRotation::Current, b"policy-current")], - vec![capture_gap(SccmRotation::Numbered(2), "parseFailed")], + 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, + std::slice::from_ref(¤t), + std::slice::from_ref(&failed), ); - let manifest: SccmBundleManifestV1 = - serde_json::from_value(manifest_value).expect("manifest shape"); - let bundle = manifest_to_client_intake_bundle(&manifest).expect("pure projection"); + 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); @@ -474,16 +477,24 @@ fn manifest_wire_and_debug_never_gain_raw_host_or_native_path_fields() { #[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")], vec![], ); value["artifacts"][0]["state"] = json!("absent"); - let manifest: SccmBundleManifestV1 = serde_json::from_value(value).expect("wire shape"); + 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 = manifest_to_client_intake_bundle(&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 @@ -559,5 +570,5 @@ fn reader_rejects_hard_linked_physical_evidence() { 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("single-link")); + assert!(error.to_string().contains("cannot be opened safely")); } From 3a12cb8db39f18148c6f5916cf08d69538032468 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 06:17:47 -0400 Subject: [PATCH 297/422] fix(sccm): retain malformed peer coverage findings --- .../src/sccm/server/windows/site_core.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs index 54cebd692..13b508ab9 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs @@ -356,6 +356,11 @@ impl<'a> SiteCoreContext<'a> { == 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; @@ -788,8 +793,16 @@ fn collect_coverage_gaps( } 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: artifact.artifact_id.clone(), + artifact_id, source_id: artifact.source_id.clone(), state, reason_code: reason_code.to_owned(), From 8a92e4cc0fd2209853ff6eb05774e43e2907cd48 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 06:33:57 -0400 Subject: [PATCH 298/422] test(sccm): cover Windows handle-bound manifest reads --- src-tauri/src/sccm/private_fs.rs | 73 ++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src-tauri/src/sccm/private_fs.rs b/src-tauri/src/sccm/private_fs.rs index 3ae7252f0..76b58a218 100644 --- a/src-tauri/src/sccm/private_fs.rs +++ b/src-tauri/src/sccm/private_fs.rs @@ -604,3 +604,76 @@ mod tests { assert_eq!(contents, "original"); } } + +#[cfg(all(test, windows))] +mod windows_tests { + use std::io::Read; + + use tempfile::tempdir; + + use super::*; + + #[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"); + + 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_rejects_a_hard_linked_final_entry() { + let temp = tempdir().expect("temporary root"); + let root = temp.path().join("bundle"); + fs::create_dir_all(&root).expect("create bundle"); + 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"); + fs::create_dir_all(&root).expect("create bundle"); + 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); + } +} From 9a030da704b7ed943341909f8e5c01cb8718ac32 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 06:41:48 -0400 Subject: [PATCH 299/422] feat(sccm): bind Windows manifest reads to handles --- src-tauri/Cargo.toml | 3 + src-tauri/src/sccm/private_fs.rs | 300 +++++++++++++++++++++++++++---- 2 files changed, 272 insertions(+), 31 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4f09dadcb..cc40f7e80 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -89,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", @@ -105,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 diff --git a/src-tauri/src/sccm/private_fs.rs b/src-tauri/src/sccm/private_fs.rs index 76b58a218..bc961fa27 100644 --- a/src-tauri/src/sccm/private_fs.rs +++ b/src-tauri/src/sccm/private_fs.rs @@ -7,11 +7,13 @@ 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 rejects the native boundary rather than making a weaker -/// pathname-based safety claim until it has equivalent handle-relative traversal. +/// 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 { @@ -38,7 +40,30 @@ pub(super) fn verify_bundle_root(bundle_root: &Path) -> Result Result<(), AppError> { - use std::os::windows::ffi::OsStrExt; +fn verify_private_directory(directory: &File) -> Result<(), AppError> { + use std::os::windows::io::AsRawHandle; - use windows::core::PCWSTR; use windows::Win32::Foundation::{CloseHandle, LocalFree, ERROR_SUCCESS, HANDLE, HLOCAL}; use windows::Win32::Security::Authorization::{ - GetExplicitEntriesFromAclW, GetNamedSecurityInfoW, GRANT_ACCESS, SET_ACCESS, - SE_FILE_OBJECT, TRUSTEE_IS_SID, + GetExplicitEntriesFromAclW, GetSecurityInfo, GRANT_ACCESS, SET_ACCESS, SE_FILE_OBJECT, + TRUSTEE_IS_SID, }; use windows::Win32::Security::{ EqualSid, GetTokenInformation, IsValidSid, IsWellKnownSid, TokenUser, @@ -201,24 +230,19 @@ fn verify_private_directory(path: &Path, _metadata: &fs::Metadata) -> Result<(), Ok(unsafe { EqualSid(owner, token_sid).is_ok() }) } - let path_wide = path - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); let mut owner = PSID::default(); let mut dacl: *mut ACL = std::ptr::null_mut(); let mut descriptor = PSECURITY_DESCRIPTOR::default(); let status = unsafe { - GetNamedSecurityInfoW( - PCWSTR(path_wide.as_ptr()), + GetSecurityInfo( + HANDLE(directory.as_raw_handle()), SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, Some(&mut owner), None, Some(&mut dacl), None, - &mut descriptor, + Some(&mut descriptor), ) }; let _descriptor = LocalAllocation(descriptor.0); @@ -302,11 +326,6 @@ fn verify_private_directory(path: &Path, _metadata: &fs::Metadata) -> Result<(), Ok(()) } -#[cfg(not(any(unix, windows)))] -fn verify_private_directory(_path: &Path, _metadata: &fs::Metadata) -> Result<(), AppError> { - Ok(()) -} - #[cfg(all(unix, test))] pub(super) fn open_file_no_follow(path: &Path) -> io::Result { use std::os::fd::AsRawFd; @@ -409,6 +428,115 @@ pub(super) fn open_file_no_follow(path: &Path) -> io::Result { require_regular_file(file) } +#[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::{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_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() { + return Err(io::Error::new( + io::ErrorKind::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") +} + #[cfg(not(any(unix, windows)))] pub(super) fn open_file_no_follow(path: &Path) -> io::Result { let file = OpenOptions::new().read(true).open(path)?; @@ -416,15 +544,24 @@ pub(super) fn open_file_no_follow(path: &Path) -> io::Result { } fn require_regular_file(file: File) -> io::Result { - 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", - )); + #[cfg(windows)] + { + require_real_windows_file(&file)?; + return 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) } - require_single_link(&file)?; - Ok(file) } #[cfg(unix)] @@ -441,7 +578,9 @@ fn require_single_link(file: &File) -> io::Result<()> { } #[cfg(windows)] -fn require_single_link(file: &File) -> io::Result<()> { +fn windows_file_information( + file: &File, +) -> io::Result { use std::os::windows::io::AsRawHandle; use windows::Win32::Storage::FileSystem::{ GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, @@ -455,6 +594,43 @@ fn require_single_link(file: &File) -> io::Result<()> { ) } .map_err(|error| io::Error::new(io::ErrorKind::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, @@ -510,6 +686,29 @@ fn invoke_open_component_hook(component: &std::ffi::CStr) { }); } +#[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; @@ -607,7 +806,9 @@ mod tests { #[cfg(all(test, windows))] mod windows_tests { + use std::cell::RefCell; use std::io::Read; + use std::rc::Rc; use tempfile::tempdir; @@ -638,6 +839,43 @@ mod windows_tests { 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"); + + 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); + set_open_component_hook(Some(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"); + set_open_component_hook(None); + 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"); From 7027287f921f30eee70d172a6b35ed74695da974 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 06:43:50 -0400 Subject: [PATCH 300/422] test(sccm): expose unconfirmed site-core phase --- .../tests/sccm_server_site_core.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index 18f92ffbb..4cfaaeb88 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -67,6 +67,8 @@ const DEFERRED_THEN_ACCEPTED: &str = concat!( "\n", "\n", ); +const TERMINAL_FAILURE_WITHOUT_SUCCESS: &str = + "\n"; #[derive(Clone)] struct Source<'a> { @@ -745,6 +747,31 @@ fn backlog_is_deferred_and_same_component_terminal_recovery_is_cited() { .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(&[ From 87e2c1857f72c070e7771216da05c3addbdf514f Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 06:44:17 -0400 Subject: [PATCH 301/422] fix(sccm): mark unconfirmed site-core phase --- .../cmtraceopen-parser/src/sccm/server/windows/site_core.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs index 13b508ab9..7347eeb3c 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs @@ -1668,8 +1668,8 @@ fn build_result_finding( .phase(SccmPhase::Unknown( result .last_successful_phase - .unwrap_or(SccmSiteCorePhase::ComponentStart) - .serialized_name() + .map(SccmSiteCorePhase::serialized_name) + .unwrap_or("siteCoreUnconfirmed") .to_owned(), )) .role(SccmRole::SiteServer) From c3f1c9498ccf1752e184f00726814d2a97d79a8d Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 07:02:23 -0400 Subject: [PATCH 302/422] test(sccm): cover Windows native manifest error mapping --- src-tauri/src/sccm/private_fs.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src-tauri/src/sccm/private_fs.rs b/src-tauri/src/sccm/private_fs.rs index bc961fa27..092e92071 100644 --- a/src-tauri/src/sccm/private_fs.rs +++ b/src-tauri/src/sccm/private_fs.rs @@ -814,6 +814,35 @@ mod windows_tests { use super::*; + #[test] + fn missing_final_component_preserves_not_found_for_legacy_fallback() { + let temp = tempdir().expect("temporary root"); + let root = temp.path().join("bundle"); + fs::create_dir_all(&root).expect("create bundle"); + + 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"); + fs::create_dir_all(&root).expect("create bundle"); + 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"); From 84079b8c30bd1ab52d3c7711ea50ee58a92276b2 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 07:04:33 -0400 Subject: [PATCH 303/422] fix(sccm): preserve native manifest open errors --- src-tauri/src/sccm/private_fs.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/sccm/private_fs.rs b/src-tauri/src/sccm/private_fs.rs index 092e92071..37b39a29a 100644 --- a/src-tauri/src/sccm/private_fs.rs +++ b/src-tauri/src/sccm/private_fs.rs @@ -438,7 +438,9 @@ fn open_relative_file_no_follow(root: &File, relative: &Path) -> io::Result io::Result u16::MAX as usize { + 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", @@ -518,6 +524,13 @@ fn open_relative_file_no_follow(root: &File, relative: &Path) -> io::Result Date: Sun, 2 Aug 2026 07:38:22 -0400 Subject: [PATCH 304/422] test(sccm): expose native reader review gaps --- src-tauri/tests/sccm_client_manifest.rs | 153 ++++++++++++++++++------ 1 file changed, 117 insertions(+), 36 deletions(-) diff --git a/src-tauri/tests/sccm_client_manifest.rs b/src-tauri/tests/sccm_client_manifest.rs index 3190fbadb..90682f2ec 100644 --- a/src-tauri/tests/sccm_client_manifest.rs +++ b/src-tauri/tests/sccm_client_manifest.rs @@ -29,9 +29,13 @@ fn sha256(value: &[u8]) -> String { } 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(POLICY_BASENAME.as_bytes()) + sha256(basename.as_bytes()) ) } @@ -85,7 +89,13 @@ fn relative_path(rotation: &SccmRotation) -> String { ) } -fn physical_artifact(rotation: SccmRotation, content: &[u8]) -> Value { +#[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!( @@ -98,7 +108,8 @@ fn physical_artifact(rotation: SccmRotation, content: &[u8]) -> Value { .as_bytes() ) ); - json!({ + PhysicalArtifactFixture { + value: json!({ "catalogEntryId": catalog_entry_id(), "logicalArtifactIds": [POLICY_GROUP], "artifactId": artifact_id, @@ -118,7 +129,9 @@ fn physical_artifact(rotation: SccmRotation, content: &[u8]) -> Value { "configmgrVersion": CONFIGMGR_VERSION, "collectedAtUtc": COLLECTED_AT_UTC, "encoding": "utf-8" - }) + }), + content: content.to_vec(), + } } fn capture_gap(rotation: SccmRotation, state: &str) -> Value { @@ -178,27 +191,28 @@ fn make_private_directory(path: &Path) { } } -fn write_native_bundle(root: &Path, artifacts: &[Value], capture_gaps: &[Value]) { +fn write_native_bundle( + root: &Path, + artifacts: &[&PhysicalArtifactFixture], + capture_gaps: &[Value], +) { make_private_directory(root); for artifact in artifacts { - let relative_path = artifact["relativePath"] + let relative_path = artifact.value["relativePath"] .as_str() .expect("physical fixture path"); - let basename = artifact["basename"] - .as_str() - .expect("physical fixture basename"); - let content = match basename { - POLICY_BASENAME => b"policy-current".as_slice(), - "PolicyAgent.log.1" => b"policy-rotation-one".as_slice(), - "PolicyAgent.log.2" => b"policy-rotation-two".as_slice(), - _ => panic!("unexpected physical fixture basename"), - }; let destination = root.join(relative_path); fs::create_dir_all(destination.parent().expect("evidence parent")) .expect("create evidence tree"); - fs::write(destination, content).expect("write synthetic evidence"); + fs::write(destination, &artifact.content).expect("write synthetic evidence"); } - let manifest = native_manifest(artifacts.to_vec(), capture_gaps.to_vec()); + 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"), @@ -226,7 +240,7 @@ 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, std::slice::from_ref(¤t), &[]); + 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"); @@ -250,6 +264,18 @@ fn validated_v1_reader_projects_one_physical_client_artifact() { assert_eq!(physical.fragment_complete, Some(false)); } +#[test] +fn validated_v1_reader_preserves_a_complete_fragment() { + let temp = tempdir().expect("temporary root"); + let bundle_root = temp.path().join("bundle"); + let mut current = physical_artifact(SccmRotation::Current, b"complete-policy-current"); + current.value["fragmentComplete"] = json!(true); + write_native_bundle(&bundle_root, &[¤t], &[]); + + let bundle = read_sccm_client_intake_bundle(&bundle_root).expect("verified pure projection"); + assert_eq!(bundle.artifacts[0].fragment_complete, Some(true)); +} + #[test] fn omitted_capped_rotation_projects_as_a_gap_without_a_fake_fragment() { let temp = tempdir().expect("temporary root"); @@ -258,7 +284,7 @@ fn omitted_capped_rotation_projects_as_a_gap_without_a_fake_fragment() { let omitted = capture_gap(SccmRotation::Numbered(1), "capped"); write_native_bundle( &bundle_root, - std::slice::from_ref(¤t), + &[¤t], std::slice::from_ref(&omitted), ); @@ -292,7 +318,7 @@ fn parse_failed_omitted_rotation_remains_coverage_only() { let failed = capture_gap(SccmRotation::Numbered(2), "parseFailed"); write_native_bundle( &bundle_root, - std::slice::from_ref(¤t), + &[¤t], std::slice::from_ref(&failed), ); let bundle = read_sccm_client_intake_bundle(&bundle_root).expect("verified pure projection"); @@ -310,14 +336,14 @@ fn parse_failed_omitted_rotation_remains_coverage_only() { 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")], + 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")], + vec![physical_artifact(SccmRotation::Current, b"policy-current").value], vec![gap; MAX_SCCM_MANIFEST_ARTIFACTS], ); let error = serde_json::from_value::(overflow) @@ -333,7 +359,7 @@ fn reader_rejects_artifacts_outside_canonical_rotation_order() { 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, current], &[]); + 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"); @@ -341,11 +367,11 @@ fn reader_rejects_artifacts_outside_canonical_rotation_order() { } #[test] -fn reader_rejects_duplicate_artifact_ids_and_relative_paths() { +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, &[current.clone(), current], &[]); + write_native_bundle(&bundle_root, &[¤t, ¤t], &[]); let error = read_sccm_manifest_or_legacy(&bundle_root).expect_err("colliding artifacts fail closed"); @@ -363,7 +389,13 @@ fn legacy_projection_never_invents_native_capture_gaps() { "collection": { "collectorProfile": "cmtrace-full-diagnostics-v1", "collectorVersion": "1.1.0", - "results": { "gaps": [] } + "results": { + "gaps": [{ + "artifactId": "configmgr-ccm-logs", + "category": "logs", + "status": "Missing" + }] + } }, "artifacts": [] })) @@ -379,6 +411,18 @@ fn legacy_projection_never_invents_native_capture_gaps() { ); assert!(manifest.capture_gaps.is_empty()); assert!(bundle.capture_gaps.is_empty()); + assert_eq!(bundle.artifacts.len(), 1); + 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] @@ -458,7 +502,7 @@ 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, std::slice::from_ref(¤t), &[]); + 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"); @@ -470,7 +514,7 @@ fn manifest_wire_and_debug_never_gain_raw_host_or_native_path_fields() { assert!(!public.contains("RealUser")); } - let mut raw_host = native_manifest(vec![current], vec![]); + 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()); } @@ -480,7 +524,7 @@ 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")], + vec![physical_artifact(SccmRotation::Current, b"policy-current").value], vec![], ); value["artifacts"][0]["state"] = json!("absent"); @@ -507,7 +551,7 @@ fn reader_enforces_the_physical_file_cap_per_canonical_source_before_evidence_re 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, &[current, rotated], &[]); + write_native_bundle(&bundle_root, &[¤t, &rotated], &[]); set_native_limits(&bundle_root, 1, 4096); let error = read_sccm_manifest_or_legacy(&bundle_root) @@ -521,7 +565,7 @@ fn reader_accepts_the_exact_physical_byte_cap_boundary() { let bundle_root = temp.path().join("bundle"); let content = b"policy-current"; let current = physical_artifact(SccmRotation::Current, content); - write_native_bundle(&bundle_root, std::slice::from_ref(¤t), &[]); + write_native_bundle(&bundle_root, &[¤t], &[]); set_native_limits(&bundle_root, 1, content.len() as u64); read_sccm_manifest_or_legacy(&bundle_root) @@ -534,7 +578,7 @@ fn reader_rejects_multi_rotation_physical_bytes_over_the_source_cap() { 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, &[current, rotated], &[]); + 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) @@ -543,13 +587,13 @@ fn reader_rejects_multi_rotation_physical_bytes_over_the_source_cap() { } #[test] -fn reader_rejects_overflowing_physical_byte_totals_before_hashing_evidence() { +fn reader_rejects_physical_byte_metadata_overflow_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["bytesCopied"] = json!(u64::MAX); + current.value["bytesCopied"] = json!(u64::MAX); let rotated = physical_artifact(SccmRotation::Numbered(1), b"policy-rotation-one"); - write_native_bundle(&bundle_root, &[current, rotated], &[]); + write_native_bundle(&bundle_root, &[¤t, &rotated], &[]); set_native_limits(&bundle_root, 2, u64::MAX); let error = read_sccm_manifest_or_legacy(&bundle_root) @@ -557,13 +601,50 @@ fn reader_rejects_overflowing_physical_byte_totals_before_hashing_evidence() { assert!(error.to_string().contains("source 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); + + 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); + + 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, std::slice::from_ref(¤t), &[]); + 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"); From daf3c8ce363555741825fd3024b7962a97c79ac4 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 07:38:54 -0400 Subject: [PATCH 305/422] test(sccm): expose final descriptor flag leak --- src-tauri/src/sccm/private_fs.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src-tauri/src/sccm/private_fs.rs b/src-tauri/src/sccm/private_fs.rs index 37b39a29a..94366014e 100644 --- a/src-tauri/src/sccm/private_fs.rs +++ b/src-tauri/src/sccm/private_fs.rs @@ -747,6 +747,25 @@ mod tests { 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 verified_root_keeps_reading_the_original_directory_after_root_replacement() { let temp = tempdir().expect("temporary root"); From d14ba3d03115fe0d6f5cd375d61d827bdaecc378 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 07:47:37 -0400 Subject: [PATCH 306/422] fix(sccm): bound native manifest evidence admission --- src-tauri/src/sccm/manifest.rs | 57 +++++++-- src-tauri/src/sccm/private_fs.rs | 156 +++++++++++++++++++----- src-tauri/tests/sccm_client_manifest.rs | 25 ++-- 3 files changed, 186 insertions(+), 52 deletions(-) diff --git a/src-tauri/src/sccm/manifest.rs b/src-tauri/src/sccm/manifest.rs index 8f0a9c942..a2a96dcdf 100644 --- a/src-tauri/src/sccm/manifest.rs +++ b/src-tauri/src/sccm/manifest.rs @@ -30,9 +30,10 @@ 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_GROUP: &str = "client-ccmsetup"; 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; pub fn read_sccm_manifest_or_legacy(bundle_root: &Path) -> Result { let verified_root = verify_bundle_root(bundle_root)?; @@ -123,7 +124,6 @@ fn validate_native_manifest( bundle_root: &VerifiedBundleRoot, manifest: &SccmBundleManifestV1, ) -> Result<(), AppError> { - validate_native_manifest_structure(manifest)?; manifest_to_client_intake_bundle(manifest)?; validate_physical_source_limits(manifest)?; for artifact in &manifest.artifacts { @@ -136,11 +136,13 @@ fn validate_native_manifest( 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( @@ -174,6 +176,28 @@ fn validate_physical_source_limits(manifest: &SccmBundleManifestV1) -> Result<() 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 @@ -780,7 +804,7 @@ fn read_legacy_client_intake_bundle( )); } known_gap_seen = true; - let catalog_id = catalog_entry_id(LEGACY_CCMSETUP_GROUP); + let catalog_id = catalog_entry_id(LEGACY_CCMSETUP_BASENAME); artifacts.push(SccmClientIntakeArtifact { artifact: SccmArtifact { artifact_id: expected_marker_artifact_id( @@ -822,7 +846,7 @@ fn legacy_gap(index: usize, gap: &Value) -> Result SccmManifestSourceState::Absent, - Some("Failed") | Some(_) | None => SccmManifestSourceState::FailedUnknownDetail, + _ => SccmManifestSourceState::FailedUnknownDetail, }; Ok(legacy_unscoped_artifact("gap", index, legacy_id, state)) } @@ -834,9 +858,9 @@ fn legacy_artifact(index: usize, artifact: &Value) -> Result SccmManifestSourceState::Absent, - Some("collected") | Some("failed") | Some(_) | None => { - SccmManifestSourceState::FailedUnknownDetail - } + // 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, @@ -937,3 +961,22 @@ fn is_safe_configmgr_version(value: &str) -> bool { 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::*; + + #[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")); + } +} diff --git a/src-tauri/src/sccm/private_fs.rs b/src-tauri/src/sccm/private_fs.rs index 94366014e..3fe0905f4 100644 --- a/src-tauri/src/sccm/private_fs.rs +++ b/src-tauri/src/sccm/private_fs.rs @@ -328,7 +328,6 @@ fn verify_private_directory(directory: &File) -> Result<(), AppError> { #[cfg(all(unix, test))] pub(super) fn open_file_no_follow(path: &Path) -> io::Result { - use std::os::fd::AsRawFd; use std::os::unix::fs::OpenOptionsExt; let file = OpenOptions::new() @@ -336,6 +335,14 @@ pub(super) fn open_file_no_follow(path: &Path) -> io::Result { .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. @@ -346,7 +353,7 @@ pub(super) fn open_file_no_follow(path: &Path) -> io::Result { if unsafe { libc::fcntl(descriptor, libc::F_SETFL, flags & !libc::O_NONBLOCK) } < 0 { return Err(io::Error::last_os_error()); } - Ok(file) + Ok(()) } #[cfg(unix)] @@ -400,7 +407,9 @@ fn open_relative_file_no_follow(root_fd: std::os::fd::RawFd, relative: &Path) -> } let opened = unsafe { File::from_raw_fd(descriptor) }; if final_component { - return require_regular_file(opened); + let opened = require_regular_file(opened)?; + clear_nonblock(&opened)?; + return Ok(opened); } let metadata = opened.metadata()?; if is_reparse_point(&metadata) || !metadata.is_dir() { @@ -416,18 +425,6 @@ fn open_relative_file_no_follow(root_fd: std::os::fd::RawFd, relative: &Path) -> unreachable!("non-empty relative paths always return from their final component") } -#[cfg(windows)] -pub(super) fn open_file_no_follow(path: &Path) -> io::Result { - use std::os::windows::fs::OpenOptionsExt; - - const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - let file = OpenOptions::new() - .read(true) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) - .open(path)?; - require_regular_file(file) -} - #[cfg(windows)] fn open_relative_file_no_follow(root: &File, relative: &Path) -> io::Result { use std::os::windows::ffi::OsStrExt; @@ -531,8 +528,7 @@ fn open_relative_file_no_follow(root: &File, relative: &Path) -> io::Result io::Result { #[cfg(windows)] { require_real_windows_file(&file)?; - return Ok(file); + Ok(file) } #[cfg(not(windows))] @@ -606,7 +602,7 @@ fn windows_file_information( &mut information, ) } - .map_err(|error| io::Error::new(io::ErrorKind::Other, error.to_string()))?; + .map_err(|error| io::Error::other(error.to_string()))?; Ok(information) } @@ -690,6 +686,24 @@ 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| { @@ -753,8 +767,7 @@ mod tests { 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::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"); @@ -766,6 +779,20 @@ mod tests { 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"); @@ -814,19 +841,18 @@ mod tests { let retired = temp.path().join("retired-evidence"); let fired = Rc::new(RefCell::new(false)); let fired_in_hook = Rc::clone(&fired); - set_open_component_hook(Some(Box::new(move |component| { + 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"); - set_open_component_hook(None); let mut contents = String::new(); opened .read_to_string(&mut contents) @@ -846,11 +872,80 @@ mod windows_tests { 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}; + + fs::create_dir_all(path).expect("create bundle directory"); + let directory = OpenOptions::new() + .read(true) + .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"); - fs::create_dir_all(&root).expect("create bundle"); + make_private_directory(&root); let verified = verify_bundle_root(&root).expect("open private root"); let error = verified @@ -864,7 +959,7 @@ mod windows_tests { fn relative_component_rejects_alternate_data_streams() { let temp = tempdir().expect("temporary root"); let root = temp.path().join("bundle"); - fs::create_dir_all(&root).expect("create 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"); @@ -880,6 +975,8 @@ mod windows_tests { 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"); @@ -905,6 +1002,8 @@ mod windows_tests { 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") @@ -916,19 +1015,18 @@ mod windows_tests { let retired = temp.path().join("retired-evidence"); let fired = Rc::new(RefCell::new(false)); let fired_in_hook = Rc::clone(&fired); - set_open_component_hook(Some(Box::new(move |component| { + 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"); - set_open_component_hook(None); let mut contents = String::new(); opened .read_to_string(&mut contents) diff --git a/src-tauri/tests/sccm_client_manifest.rs b/src-tauri/tests/sccm_client_manifest.rs index 90682f2ec..5e7a92cb5 100644 --- a/src-tauri/tests/sccm_client_manifest.rs +++ b/src-tauri/tests/sccm_client_manifest.rs @@ -282,11 +282,7 @@ fn omitted_capped_rotation_projects_as_a_gap_without_a_fake_fragment() { 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), - ); + 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!( @@ -316,11 +312,7 @@ fn parse_failed_omitted_rotation_remains_coverage_only() { 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), - ); + 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); @@ -416,13 +408,14 @@ fn legacy_projection_never_invents_native_capture_gaps() { let expected_artifact_id = format!( "sccm-artifact:v1:sha256:{}", sha256( - format!( - "marker:v1:{expected_catalog_id}:absent:current:ccmsetup.log:unscoped" - ) - .as_bytes() + format!("marker:v1:{expected_catalog_id}:absent:current:ccmsetup.log:unscoped") + .as_bytes() ) ); - assert_eq!(bundle.artifacts[0].artifact.artifact_id, expected_artifact_id); + assert_eq!( + bundle.artifacts[0].artifact.artifact_id, + expected_artifact_id + ); } #[test] @@ -598,7 +591,7 @@ fn reader_rejects_physical_byte_metadata_overflow_before_evidence_reads() { let error = read_sccm_manifest_or_legacy(&bundle_root) .expect_err("overflowing source bytes fail before unbounded evidence verification"); - assert!(error.to_string().contains("source byte cap")); + assert!(error.to_string().contains("physical artifact byte cap")); } #[test] From 0eec38e1fb9acc894e44220dd04706cdf46949a5 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 07:49:04 -0400 Subject: [PATCH 307/422] test(sccm): pin native manifest nofollow error --- src-tauri/tests/sccm_client_manifest.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/tests/sccm_client_manifest.rs b/src-tauri/tests/sccm_client_manifest.rs index 5e7a92cb5..360b000ec 100644 --- a/src-tauri/tests/sccm_client_manifest.rs +++ b/src-tauri/tests/sccm_client_manifest.rs @@ -469,6 +469,9 @@ fn reader_opens_manifest_without_following_a_symlink() { .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)] From b69d5a1b771525341583454ad8105f21560573f8 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 07:49:46 -0400 Subject: [PATCH 308/422] fix(sccm): remove unused non-native test opener --- src-tauri/src/sccm/private_fs.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src-tauri/src/sccm/private_fs.rs b/src-tauri/src/sccm/private_fs.rs index 3fe0905f4..28db10c79 100644 --- a/src-tauri/src/sccm/private_fs.rs +++ b/src-tauri/src/sccm/private_fs.rs @@ -546,12 +546,6 @@ fn open_relative_file_no_follow(root: &File, relative: &Path) -> io::Result io::Result { - let file = OpenOptions::new().read(true).open(path)?; - require_regular_file(file) -} - fn require_regular_file(file: File) -> io::Result { #[cfg(windows)] { From c33bab701a273ea421cf2258971aa5e7210e9ca7 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 08:03:04 -0400 Subject: [PATCH 309/422] test(sccm): prove manifest caps precede evidence reads --- src-tauri/tests/sccm_client_manifest.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src-tauri/tests/sccm_client_manifest.rs b/src-tauri/tests/sccm_client_manifest.rs index 360b000ec..5c1c7a622 100644 --- a/src-tauri/tests/sccm_client_manifest.rs +++ b/src-tauri/tests/sccm_client_manifest.rs @@ -220,6 +220,13 @@ fn write_native_bundle( .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( @@ -605,6 +612,7 @@ fn reader_rejects_a_client_owned_per_artifact_ceiling_before_evidence_reads() { 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"); @@ -628,6 +636,9 @@ fn reader_rejects_a_client_owned_aggregate_ceiling_before_evidence_reads() { 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"); From f6376cfe10cbcaa0b40a6af2263a9722cc5f2a81 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 08:14:42 -0400 Subject: [PATCH 310/422] test(sccm): require reusable native projection --- src-tauri/src/sccm/manifest.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src-tauri/src/sccm/manifest.rs b/src-tauri/src/sccm/manifest.rs index a2a96dcdf..256dbb024 100644 --- a/src-tauri/src/sccm/manifest.rs +++ b/src-tauri/src/sccm/manifest.rs @@ -966,6 +966,14 @@ fn is_four_ascii_digits(value: &str) -> bool { mod tests { use super::*; + #[test] + fn native_validation_returns_its_reusable_intake_projection() { + let _validate: fn( + &VerifiedBundleRoot, + &SccmBundleManifestV1, + ) -> Result = validate_native_manifest; + } + #[test] fn client_owned_physical_byte_budget_accepts_exact_ceilings_without_opening_evidence() { let mut total = 0; From aad5e04f133af19be937491023bad651651e5bd6 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 08:15:40 -0400 Subject: [PATCH 311/422] fix(sccm): reuse validated native projection --- src-tauri/src/sccm/manifest.rs | 45 +++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/sccm/manifest.rs b/src-tauri/src/sccm/manifest.rs index 256dbb024..40eec9bf5 100644 --- a/src-tauri/src/sccm/manifest.rs +++ b/src-tauri/src/sccm/manifest.rs @@ -35,7 +35,17 @@ 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; -pub fn read_sccm_manifest_or_legacy(bundle_root: &Path) -> Result { +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) => { @@ -47,11 +57,14 @@ pub fn read_sccm_manifest_or_legacy(bundle_root: &Path) -> Result { - read_legacy_manifest(&verified_root) + read_legacy_manifest(&verified_root).map(ValidatedManifestRead::Legacy) } Err(_) => Err(AppError::InvalidInput( "SCCM manifest cannot be opened safely".to_owned(), @@ -59,6 +72,13 @@ pub fn read_sccm_manifest_or_legacy(bundle_root: &Path) -> Result 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 { @@ -112,26 +132,27 @@ fn manifest_to_client_intake_bundle( pub fn read_sccm_client_intake_bundle( bundle_root: &Path, ) -> Result { - let manifest = read_sccm_manifest_or_legacy(bundle_root)?; - if manifest.provenance == SccmManifestProvenance::LegacyGenericUnscoped { - let verified_root = verify_bundle_root(bundle_root)?; - return read_legacy_client_intake_bundle(&verified_root); + 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) + } } - manifest_to_client_intake_bundle(&manifest) } fn validate_native_manifest( bundle_root: &VerifiedBundleRoot, manifest: &SccmBundleManifestV1, -) -> Result<(), AppError> { - manifest_to_client_intake_bundle(manifest)?; +) -> 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(()) + Ok(intake_bundle) } fn validate_physical_source_limits(manifest: &SccmBundleManifestV1) -> Result<(), AppError> { From efed80b0edc3d7b60f71c48310f81cfe7bd05b18 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 08:21:10 -0400 Subject: [PATCH 312/422] test(sccm): count native intake projection passes --- src-tauri/src/sccm/manifest.rs | 39 +++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/sccm/manifest.rs b/src-tauri/src/sccm/manifest.rs index 40eec9bf5..191421810 100644 --- a/src-tauri/src/sccm/manifest.rs +++ b/src-tauri/src/sccm/manifest.rs @@ -987,12 +987,41 @@ fn is_four_ascii_digits(value: &str) -> bool { mod tests { use super::*; + #[cfg(unix)] #[test] - fn native_validation_returns_its_reusable_intake_projection() { - let _validate: fn( - &VerifiedBundleRoot, - &SccmBundleManifestV1, - ) -> Result = validate_native_manifest; + 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] From afb06baa19770768ce4b1a25218f958e0f19d227 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 08:21:37 -0400 Subject: [PATCH 313/422] test(sccm): prove one native intake projection pass --- src-tauri/src/sccm/manifest.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src-tauri/src/sccm/manifest.rs b/src-tauri/src/sccm/manifest.rs index 191421810..8f23a54cb 100644 --- a/src-tauri/src/sccm/manifest.rs +++ b/src-tauri/src/sccm/manifest.rs @@ -35,6 +35,21 @@ 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(test)] +thread_local! { + static INTAKE_PROJECTION_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +fn reset_intake_projection_count() { + INTAKE_PROJECTION_COUNT.with(|count| count.set(0)); +} + +#[cfg(test)] +fn intake_projection_count() -> usize { + INTAKE_PROJECTION_COUNT.with(std::cell::Cell::get) +} + enum ValidatedManifestRead { Native { manifest: SccmBundleManifestV1, @@ -82,6 +97,9 @@ pub fn read_sccm_manifest_or_legacy(bundle_root: &Path) -> Result Result { + #[cfg(test)] + INTAKE_PROJECTION_COUNT.with(|count| count.set(count.get().saturating_add(1))); + validate_native_manifest_structure(manifest)?; let artifacts = manifest .artifacts From 1458575127718a95068a09d1cf919486bb1213b2 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 08:27:35 -0400 Subject: [PATCH 314/422] fix(sccm): gate projection counter to Unix tests --- src-tauri/src/sccm/manifest.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/sccm/manifest.rs b/src-tauri/src/sccm/manifest.rs index 8f23a54cb..6883f7795 100644 --- a/src-tauri/src/sccm/manifest.rs +++ b/src-tauri/src/sccm/manifest.rs @@ -35,17 +35,17 @@ 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(test)] +#[cfg(all(test, unix))] thread_local! { static INTAKE_PROJECTION_COUNT: std::cell::Cell = const { std::cell::Cell::new(0) }; } -#[cfg(test)] +#[cfg(all(test, unix))] fn reset_intake_projection_count() { INTAKE_PROJECTION_COUNT.with(|count| count.set(0)); } -#[cfg(test)] +#[cfg(all(test, unix))] fn intake_projection_count() -> usize { INTAKE_PROJECTION_COUNT.with(std::cell::Cell::get) } @@ -97,7 +97,7 @@ pub fn read_sccm_manifest_or_legacy(bundle_root: &Path) -> Result Result { - #[cfg(test)] + #[cfg(all(test, unix))] INTAKE_PROJECTION_COUNT.with(|count| count.set(count.get().saturating_add(1))); validate_native_manifest_structure(manifest)?; From c4e4c9baeffbf2eede6ed367870f5d18b57e6e21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:32:05 +0000 Subject: [PATCH 315/422] fix(sccm/test): open fixture directory with READ_CONTROL|WRITE_DAC for SetSecurityInfo make_private_directory was opening the bundle fixture directory with .read(true) (GENERIC_READ), which includes READ_CONTROL but not WRITE_DAC. SetSecurityInfo with DACL_SECURITY_INFORMATION requires WRITE_DAC on the handle; without it the call returns ERROR_ACCESS_DENIED (WIN32_ERROR(5)), failing the four windows_tests that call make_private_directory on GitHub Actions hosted Windows runners. Switch to .access_mode(READ_CONTROL.0 | WRITE_DAC.0) so both the owner query (GetSecurityInfo) and the DACL write (SetSecurityInfo) succeed on a handle with exactly the rights they need. --- src-tauri/src/sccm/private_fs.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/sccm/private_fs.rs b/src-tauri/src/sccm/private_fs.rs index 28db10c79..0a15e5b8b 100644 --- a/src-tauri/src/sccm/private_fs.rs +++ b/src-tauri/src/sccm/private_fs.rs @@ -870,7 +870,9 @@ mod windows_tests { use std::os::windows::{fs::OpenOptionsExt, io::AsRawHandle}; use windows::core::PWSTR; - use windows::Win32::Foundation::{LocalFree, ERROR_SUCCESS, HANDLE, HLOCAL}; + use windows::Win32::Foundation::{ + LocalFree, ERROR_SUCCESS, HANDLE, HLOCAL, READ_CONTROL, WRITE_DAC, + }; 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, @@ -882,8 +884,11 @@ mod windows_tests { use windows::Win32::Storage::FileSystem::{FILE_ALL_ACCESS, FILE_FLAG_BACKUP_SEMANTICS}; 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() - .read(true) + .access_mode(READ_CONTROL.0 | WRITE_DAC.0) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) .open(path) .expect("open bundle directory for DACL fixture"); From 7d336185ddf46e27489bf573b3ce734f99dad45a Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 08:38:36 -0400 Subject: [PATCH 316/422] test(sccm): prove source caps precede evidence reads --- src-tauri/tests/sccm_client_manifest.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/tests/sccm_client_manifest.rs b/src-tauri/tests/sccm_client_manifest.rs index 5c1c7a622..9727f2b82 100644 --- a/src-tauri/tests/sccm_client_manifest.rs +++ b/src-tauri/tests/sccm_client_manifest.rs @@ -556,6 +556,7 @@ fn reader_enforces_the_physical_file_cap_per_canonical_source_before_evidence_re 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"); From bcb4ac54fff4912b55396822514ca943422fb4ac Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 08:53:34 -0400 Subject: [PATCH 317/422] fix(sccm): import Windows fixture rights from storage --- src-tauri/src/sccm/private_fs.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/sccm/private_fs.rs b/src-tauri/src/sccm/private_fs.rs index 0a15e5b8b..3ce4af30f 100644 --- a/src-tauri/src/sccm/private_fs.rs +++ b/src-tauri/src/sccm/private_fs.rs @@ -870,9 +870,7 @@ mod windows_tests { use std::os::windows::{fs::OpenOptionsExt, io::AsRawHandle}; use windows::core::PWSTR; - use windows::Win32::Foundation::{ - LocalFree, ERROR_SUCCESS, HANDLE, HLOCAL, READ_CONTROL, WRITE_DAC, - }; + 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, @@ -881,7 +879,9 @@ mod windows_tests { 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}; + 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). From 244448b720f267351155f2debcb2df32829ba049 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 09:23:31 -0400 Subject: [PATCH 318/422] test(sccm): normalize Windows link fixture ACLs --- src-tauri/src/sccm/private_fs.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/sccm/private_fs.rs b/src-tauri/src/sccm/private_fs.rs index 3ce4af30f..eda140314 100644 --- a/src-tauri/src/sccm/private_fs.rs +++ b/src-tauri/src/sccm/private_fs.rs @@ -1038,7 +1038,7 @@ mod windows_tests { fn verified_root_rejects_a_hard_linked_final_entry() { let temp = tempdir().expect("temporary root"); let root = temp.path().join("bundle"); - fs::create_dir_all(&root).expect("create 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"); @@ -1057,7 +1057,7 @@ mod windows_tests { let temp = tempdir().expect("temporary root"); let root = temp.path().join("bundle"); - fs::create_dir_all(&root).expect("create 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() { From 53c49338d326f2deab1f93165233f94da09c0ab2 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 2 Aug 2026 09:27:05 -0400 Subject: [PATCH 319/422] test(sccm): exercise physical byte overflow guard --- src-tauri/src/sccm/manifest.rs | 15 +++++++++++++++ src-tauri/tests/sccm_client_manifest.rs | 10 +++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/sccm/manifest.rs b/src-tauri/src/sccm/manifest.rs index 6883f7795..859c38ca3 100644 --- a/src-tauri/src/sccm/manifest.rs +++ b/src-tauri/src/sccm/manifest.rs @@ -1055,4 +1055,19 @@ mod tests { .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/tests/sccm_client_manifest.rs b/src-tauri/tests/sccm_client_manifest.rs index 9727f2b82..39303a1f2 100644 --- a/src-tauri/tests/sccm_client_manifest.rs +++ b/src-tauri/tests/sccm_client_manifest.rs @@ -591,17 +591,17 @@ fn reader_rejects_multi_rotation_physical_bytes_over_the_source_cap() { } #[test] -fn reader_rejects_physical_byte_metadata_overflow_before_evidence_reads() { +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); - let rotated = physical_artifact(SccmRotation::Numbered(1), b"policy-rotation-one"); - write_native_bundle(&bundle_root, &[¤t, &rotated], &[]); - set_native_limits(&bundle_root, 2, 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("overflowing source bytes fail before unbounded evidence verification"); + .expect_err("extreme metadata fails before unbounded evidence verification"); assert!(error.to_string().contains("physical artifact byte cap")); } From 76784c38e7b9b0893afabe15ef88bcc0b89f9801 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:04:39 -0400 Subject: [PATCH 320/422] test(sccm): reject cyclic advanced-role supersession (#442) * test(sccm): reject cyclic advanced-role supersession * fix(sccm): reject cyclic advanced-role supersession --- .../sccm_server_advanced_roles_catalog.rs | 122 +++++++++++++++++- 1 file changed, 119 insertions(+), 3 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs b/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs index 8114214cd..91b8dfbb8 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_advanced_roles_catalog.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -601,6 +601,81 @@ fn validate_card_with_inventory(card: &SourceCard, inventory: &BTreeSet) 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), @@ -650,9 +725,9 @@ fn candidate_catalog_is_typed_private_and_not_semantically_admitted() { cards.len(), "source-card IDs must be unique" ); + let validations = validate_catalog(&cards); let mut card_ids = Vec::new(); - for (filename, card) in SOURCE_CARDS.into_iter().zip(cards) { - let validation = validate_card_with_inventory(&card, &inventory); + for ((filename, card), validation) in SOURCE_CARDS.into_iter().zip(cards).zip(validations) { assert!( validation.valid, "{filename}: {}", @@ -813,6 +888,47 @@ fn deprecation_requires_an_explicit_successor_and_never_panics() { ); } +#[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() From 4684eb45bb78e2b3bb27c6226de44aa1535c1caf Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:05:03 -0400 Subject: [PATCH 321/422] fix(sccm): bind Management Point analysis to canonical intake (#443) * test(sccm): require canonical MP intake adapter * fix(sccm): reduce management point from server intake * test(sccm): restrict raw management point reducer * test(sccm): reject forged MP intake evidence * fix(sccm): bind MP evidence to canonical intake * test(sccm): expose mutable MP intake metadata * test(sccm): expose MP profile version split * test(sccm): expose MP completeness split * fix(sccm): bind management point intake metadata * test(sccm): bound intake integrity retention * fix(sccm): compact intake integrity binding * test(sccm): preflight nested intake mutations * test(sccm): require single-pass integrity hashing * fix(sccm): preflight sealed intake structure * test(sccm): harden MP intake adapter assertions * test(sccm): expose MP profile prefix drift * test(sccm): align MP fixture profile prefixes * test(sccm): require forward-compatible MP intake errors * fix(sccm): harden MP intake API contracts * test(sccm): harden MP profile fixture contract * test(sccm): scope MP profile fixture validation --- crates/cmtraceopen-parser/src/sccm/catalog.rs | 2 +- .../src/sccm/server/windows/intake.rs | 816 ++++++- .../sccm/server/windows/management_point.rs | 264 ++- .../server/windows/management_point_tests.rs | 2014 +++++++++++++++++ .../auth-failure/expected.json | 4 +- .../auth-failure/manifest.json | 2 +- .../server-mp-policy/current/MP_GetPolicy.log | 1 + .../manifest.json | 38 + .../healthy-policy/expected.json | 8 +- .../healthy-policy/manifest.json | 6 +- .../iis-supplemental/expected.json | 10 +- .../iis-supplemental/manifest.json | 8 +- .../management-point/incomplete/expected.json | 8 +- .../management-point/incomplete/manifest.json | 6 +- .../location-failure/expected.json | 8 +- .../location-failure/manifest.json | 6 +- .../policy-failure/expected.json | 8 +- .../policy-failure/manifest.json | 6 +- .../registration-failure/expected.json | 6 +- .../registration-failure/manifest.json | 4 +- .../rotation-boundary/expected.json | 4 +- .../rotation-boundary/manifest.json | 4 +- .../unrelated-client-like-key/expected.json | 6 +- .../unrelated-client-like-key/manifest.json | 4 +- .../tests/sccm_server_management_point.rs | 1730 ++------------ 25 files changed, 3371 insertions(+), 1602 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/server/windows/management_point_tests.rs create mode 100644 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 create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/canonical-intake-policy-scope/manifest.json diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs index 127df01f5..3ad9008a2 100644 --- a/crates/cmtraceopen-parser/src/sccm/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -27,7 +27,7 @@ pub enum SccmArtifactFamily { } impl SccmArtifactFamily { - fn serialized_name(&self) -> &str { + pub(crate) fn serialized_name(&self) -> &str { match self { Self::ClientSetup => "clientSetup", Self::ClientHealth => "clientHealth", diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 90f136a8d..08bcb3d42 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -1,5 +1,6 @@ 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}; @@ -63,6 +64,11 @@ pub struct SccmServerIntakeAssessment { 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, @@ -187,6 +193,128 @@ impl SccmServerIntakeAssessment { pub fn privacy_extensions(&self) -> &[SccmServerOpaqueExtension] { &self.privacy_extensions } + + 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) + } +} + +/// 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)] +struct CoverageIntegrityIdentity { + producer_role: String, + workflow_subject_role: String, + source_id: String, + state: String, +} + +#[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.workflow_subject_role.len() + + identity.source_id.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)] @@ -547,15 +675,20 @@ pub fn assess_server_intake( 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: 1, + schema_version, topology, artifacts, coverage, evidence, findings: Vec::new(), next_artifact_requests, + intake_integrity, extensions: normalize_opaque_extensions( &manifest.extensions, SccmServerIntakeError::MalformedManifest, @@ -1063,6 +1196,644 @@ fn payload_sha256(bytes: &[u8]) -> String { 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.workflow_subject_role.as_ref().map(role_sort_key), + )?; + 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 mut normalized_topology = topology.clone(); + normalized_topology + .roles_observed + .sort_by(|left, right| role_sort_key(left).cmp(role_sort_key(right))); + if normalized_topology + .roles_observed + .windows(2) + .any(|roles| roles[0] == roles[1]) + { + return None; + } + + 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 = CoverageIntegrityIdentity { + producer_role: role_sort_key(&record.producer_role).to_owned(), + workflow_subject_role: record + .workflow_subject_role + .as_ref() + .map(role_sort_key) + .unwrap_or_default() + .to_owned(), + source_id: record.source_id.clone(), + state: coverage_sort_key(&record.state).to_owned(), + }; + 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>, @@ -2214,6 +2985,49 @@ define_raw_server_wire! { } } +#[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::*; diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs index 2eb312dc7..51367bd66 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point.rs @@ -1,7 +1,7 @@ //! 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.0000` corpus. A +//! `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 @@ -13,6 +13,7 @@ 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; @@ -23,14 +24,37 @@ use crate::sccm::{ 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.0000"; +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, @@ -271,7 +295,216 @@ struct ReducedTransaction { coverage_gap: Option, } -pub fn analyze_management_point(bundle: &SccmManagementPointBundle) -> SccmManagementPointAnalysis { +/// 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() @@ -284,7 +517,7 @@ pub fn analyze_management_point(bundle: &SccmManagementPointBundle) -> SccmManag let topology_site_code = normalize_site_code(&bundle.topology.site_code); let topology_is_valid = topology_site_code.is_some() - && valid_safe_handle(&bundle.topology.management_point_host_handle, "safe:mp:"); + && valid_management_point_handle(&bundle.topology.management_point_host_handle); let mut facts_by_request: BTreeMap> = BTreeMap::new(); let mut rejected_references = Vec::new(); @@ -842,7 +1075,7 @@ fn parse_fact( 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_safe_handle(&management_point_host_handle, "safe:mp:") + || !valid_management_point_handle(&management_point_host_handle) { return None; } @@ -1093,6 +1326,27 @@ fn valid_safe_handle(value: &str, prefix: &str) -> bool { .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 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..7cbb63b5f --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point_tests.rs @@ -0,0 +1,2014 @@ +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_json = fs::read_to_string(directory.join("manifest.json")) + .expect("server intake fixture manifest must be readable"); + let manifest: Value = serde_json::from_str(&manifest_json) + .expect("server intake 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("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(&manifest_json, &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" + ); + 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_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/tests/fixtures/sccm/server/management-point/auth-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/auth-failure/expected.json index 89ae7d084..74819130f 100644 --- 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 @@ -4,14 +4,14 @@ "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"}, + "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.0000","collectedUtc":"2026-07-30T02:00:05Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + {"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": [{ 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 index e917f861e..c4c0cf1f6 100644 --- 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 @@ -28,7 +28,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T02:00:05Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, 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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy/expected.json index 40c45f3c8..6175be402 100644 --- 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 @@ -4,7 +4,7 @@ "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"}, + "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, @@ -14,9 +14,9 @@ {"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.0000","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.0000","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.0000","collectedUtc":"2026-07-30T01:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + {"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": [{ 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 index a643b1b7a..1e16ae7b8 100644 --- 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 @@ -28,7 +28,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T01:00:10Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, @@ -48,7 +48,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T01:00:10Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, @@ -68,7 +68,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T01:00:10Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, 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 index 37d4e954c..d8c4b201f 100644 --- 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 @@ -4,7 +4,7 @@ "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"}, + "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, @@ -15,11 +15,11 @@ {"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.0000","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.0000","collectedUtc":"2026-07-30T06:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"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.0000","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.0000","collectedUtc":"2026-07-30T06:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + {"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": [{ 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 index 4bf80bb42..3c1682d7f 100644 --- 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 @@ -28,7 +28,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T06:00:10Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, @@ -48,7 +48,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T06:00:10Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, @@ -86,7 +86,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T06:00:10Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, @@ -107,7 +107,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T06:00:10Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, 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 index 748714bf1..4639f41bf 100644 --- 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 @@ -4,7 +4,7 @@ "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"}, + "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, @@ -14,9 +14,9 @@ {"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.0000","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.0000","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.0000","collectedUtc":"2026-07-30T07:00:05Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + {"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": [{ 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 index 74a96b765..8774b99ea 100644 --- 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 @@ -28,7 +28,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T07:00:05Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, @@ -48,7 +48,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T07:00:05Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, @@ -68,7 +68,7 @@ "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.0000", + "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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/location-failure/expected.json index b5af813a6..b92eb59b4 100644 --- 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 @@ -4,7 +4,7 @@ "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"}, + "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, @@ -14,9 +14,9 @@ {"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.0000","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.0000","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.0000","collectedUtc":"2026-07-30T04:00:06Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + {"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": [{ 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 index 6921c8ea0..188a963ae 100644 --- 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 @@ -28,7 +28,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T04:00:06Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, @@ -48,7 +48,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T04:00:06Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, @@ -68,7 +68,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T04:00:06Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, 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 index bd0925ad3..a06bef5bf 100644 --- 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 @@ -4,7 +4,7 @@ "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"}, + "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, @@ -14,9 +14,9 @@ {"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.0000","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.0000","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.0000","collectedUtc":"2026-07-30T05:10:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + {"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": [ 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 index 6dd3aa8a2..a641be5db 100644 --- 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 @@ -28,7 +28,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T05:10:10Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, @@ -48,7 +48,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T05:10:10Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, @@ -68,7 +68,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T05:10:10Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, 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 index 4256cfa59..d6b10c478 100644 --- 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 @@ -4,15 +4,15 @@ "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"}, + "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.0000","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.0000","collectedUtc":"2026-07-30T03:00:05Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + {"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": [{ 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 index 351fd1848..18b934a8e 100644 --- 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 @@ -28,7 +28,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T03:00:05Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, @@ -48,7 +48,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T03:00:05Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, 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 index 25758d14f..a7cd947ad 100644 --- 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 @@ -13,8 +13,8 @@ {"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.0000","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.0000","collectedUtc":"2026-07-30T09:00:10Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false}, + {"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, 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 index f5db8f5e5..3ad9a34bb 100644 --- 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 @@ -28,7 +28,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T09:00:10Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, @@ -48,7 +48,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T09:00:10Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, 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 index 993afbdd2..8b374d1a3 100644 --- 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 @@ -4,7 +4,7 @@ "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"}, + "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, @@ -14,8 +14,8 @@ {"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.0000","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.0000","collectedUtc":"2026-07-30T08:00:05Z","encoding":"utf-8","byteLimit":4096,"limitApplied":false} + {"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": [], 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 index 062e28f03..2476e6a9f 100644 --- 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 @@ -28,7 +28,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T08:00:05Z", "bytesCopied": 0, "relativePath": null @@ -46,7 +46,7 @@ "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.0000", + "sourceVersion": "5.00.TEST", "collectedUtc": "2026-07-30T08:00:05Z", "encoding": "utf-8", "collectionLimit": {"byteLimit":4096,"limitApplied":false}, diff --git a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs index 10aeb6132..35ff5dbed 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_management_point.rs @@ -1,74 +1,21 @@ -use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; use cmtraceopen_parser::sccm::server::windows::{ - analyze_management_point, declared_server_source_catalog, SccmManagementPointBundle, - SccmManagementPointSource, SccmManagementPointTopology, + analyze_management_point_from_server_intake, assess_server_intake, + SccmManagementPointIntakeError, SccmServerArtifactPayload, }; -use cmtraceopen_parser::sccm::{ - declared_source_catalog, normalize_ccm_artifact, SccmArtifact, SccmArtifactFamily, - SccmCoverageState, SccmRole, SccmRotation, -}; -use serde::Deserialize; -use serde_json::{json, Value}; +use cmtraceopen_parser::sccm::SccmCoverageState; +use serde_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, -} +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")) @@ -76,1528 +23,229 @@ fn fixture_directory(scenario: &str) -> PathBuf { .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 expected_transaction_projection(expected: &Value) -> Vec { - expected["transactions"] +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("expected transactions") + .expect("canonical MP fixture artifacts") .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, + .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() + .collect::>(); + assess_server_intake(&manifest_json, &payloads) + .expect("canonical MP fixture must satisfy server intake") } -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() -} +#[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"); -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; - }; + 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")); - 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) - }) + 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" + )); } -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::>(); +#[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 expected_transaction in expected["transactions"] - .as_array() - .expect("expected transactions") - { - let transaction_id = expected_transaction["transactionId"] + 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() - .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"]; + .filter(|prefix| !prefix.is_empty()) + .expect("selected MP profile must declare a nonempty source version prefix"); 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" + profile["profileId"].as_str(), + Some(SYNTHETIC_MP_PROFILE_ID), + "{}: selected fixture must retain the synthetic MP profile", + scenario_directory.display() ); - - let expected_ranges = expected_transaction["evidence"] - .as_array() - .expect("expected transaction evidence"); - let actual_references = actual["evidence"] + let validated_families = profile["validatedArtifactFamilies"] .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" + .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() ); - 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"] + let expected_artifacts = expected["artifactProvenance"] .as_array() - .expect("transaction observations"); - assert_eq!( - observations.len(), - expected_transaction["observations"] - .as_array() - .expect("expected transaction observations") - .len(), - "{scenario}: observation count" + .expect("MP expected artifact provenance must be an array"); + assert!( + !expected_artifacts.is_empty(), + "{}: selected MP fixture must retain artifact provenance", + scenario_directory.display() ); - 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") + 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() - .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" + .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() ); - 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 -} + joined_provenance_rows += 1; -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"); + 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; + } - 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" => { + if artifact_id == "mp-iis-control-current" { assert!( - !terminal.is_empty(), - "high confirmed failure needs terminal evidence" + !profile_validated, + "site-server mpcontrol provenance must not enter the Management Point profile" ); } - "insufficientEvidence" => { - assert!(!gaps.is_empty(), "insufficient evidence needs a gap"); + if artifact_id == "mp-iis-optional-skipped" { assert!( - !requests.is_empty(), - "insufficient evidence needs a request" + !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; } - _ => {} - } - } -} - -#[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(&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 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(&bundle)).expect("analysis JSON"); - - let mut reordered = bundle.clone(); - reordered.sources.reverse(); - reordered.evidence.reverse(); - let actual = - serde_json::to_string(&analyze_management_point(&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(&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(&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(&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" + expected_scenarios, EXPECTED_MP_FIXTURE_SCENARIOS, + "MP fixture scenario cardinality drifted" ); -} - -#[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" + selected_scenarios, SELECTED_MP_FIXTURE_SCENARIOS, + "selected MP fixture scenario cardinality drifted" ); - - 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" + joined_provenance_rows, SELECTED_MP_JOINED_PROVENANCE_ROWS, + "selected MP joined provenance cardinality drifted" ); - 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" + profile_validated_rows, SELECTED_MP_PROFILE_VALIDATED_ROWS, + "selected MP profile-validated provenance cardinality drifted" ); - assert_eq!(subject_row.logical_names, ["mpcontrol"].as_slice()); -} - -fn analysis_value(bundle: &SccmManagementPointBundle) -> Value { - serde_json::to_value(analyze_management_point(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" + optional_iis_version_verified, + "optional IIS provenance regression assertion did not run" ); } #[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(&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" - ); +#[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", + }; - 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" - ); + assert_eq!(category, "topology"); } From c622c9ecaea75fef16ec5a5b7d865900325088f5 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:06:32 -0400 Subject: [PATCH 322/422] feat(sccm): add bounded client discovery normalization (#444) * test(sccm): define bounded client discovery contract * test(sccm): pin discovery budget and privacy boundaries * feat(sccm): normalize bounded client discovery * test(sccm): expose discovery bound and conflict gaps * test(sccm): isolate discovery construction probes * test(sccm): pin bounded discovery source fairness * fix(sccm): bound deterministic client discovery * test(sccm): expose late discovery conflicts * fix(sccm): reject all conflicting discovery observations * test(sccm): preserve malformed root discovery skip * fix(sccm): skip malformed discovery root handles * perf(sccm): short-circuit discovery conflict checks * test(sccm): pin conflict error privacy * style(sccm): satisfy bounded discovery lint * test(sccm): expose discovery work bound * fix(sccm): bound client discovery normalization work * test(sccm): expose discovery review regressions * fix(sccm): stabilize discovery normalization identities --- src-tauri/Cargo.toml | 4 + src-tauri/src/sccm/discovery.rs | 669 ++++++++++++++++++++++ src-tauri/src/sccm/mod.rs | 2 + src-tauri/tests/sccm_client_discovery.rs | 691 +++++++++++++++++++++++ 4 files changed, 1366 insertions(+) create mode 100644 src-tauri/src/sccm/discovery.rs create mode 100644 src-tauri/tests/sccm_client_discovery.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cc40f7e80..7be2c6242 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -135,6 +135,10 @@ required-features = ["sysmon"] name = "sccm_client_manifest" required-features = ["sccm-diagnostics"] +[[test]] +name = "sccm_client_discovery" +required-features = ["sccm-diagnostics"] + [[bench]] name = "intune_pipeline" harness = false diff --git a/src-tauri/src/sccm/discovery.rs b/src-tauri/src/sccm/discovery.rs new file mode 100644 index 000000000..8786ba43b --- /dev/null +++ b/src-tauri/src/sccm/discovery.rs @@ -0,0 +1,669 @@ +//! Read-only normalization of already-observed SCCM client source candidates. + +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +#[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; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SccmClientDiscoveryObservationState { + Found, + AccessDenied, + NotFound, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SccmClientDiscoveryState { + Discovered, + AccessDenied, + NotFound, + Capped, +} + +#[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, +} + +#[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, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct PhysicalObservationKey { + root_handle: String, + canonical_basename: String, + rotation: String, +} + +struct NormalizedObservation<'a> { + observation: &'a SccmClientDiscoveryObservation, + canonical_basename: String, + logical_artifact_ids: Vec, +} + +#[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) }; +} + +pub fn discover_client_sources( + input: &SccmClientDiscoveryInput, +) -> Result { + if input.observations.len() > MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS { + return Err(SccmClientDiscoveryError::ObservationLimitExceeded); + } + + let observations = normalize_observations(input)?; + let mut found_per_source = BTreeMap::<(String, String), usize>::new(); + let mut capped_sources = BTreeSet::<(String, String)>::new(); + let mut declarations = Vec::with_capacity( + input + .observations + .len() + .min(MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS), + ); + let mut first_omitted: Option<(NormalizedObservation<'_>, SccmClientDiscoveryState)> = None; + + 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; + }; + + if declarations.len() < MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS - 1 { + declarations.push(declaration_from_candidate( + candidate_from_observation(&observation).expect("prevalidated observation"), + state, + )); + } else if let Some((first_omitted, _)) = first_omitted { + declarations.push(declaration_from_candidate( + candidate_from_observation(&first_omitted).expect("prevalidated observation"), + SccmClientDiscoveryState::Capped, + )); + return Ok(SccmClientDiscoveryResult { declarations }); + } else { + first_omitted = Some((observation, state)); + } + } + + if let Some((last, state)) = first_omitted { + declarations.push(declaration_from_candidate( + candidate_from_observation(&last).expect("prevalidated observation"), + state, + )); + } + + Ok(SccmClientDiscoveryResult { declarations }) +} + +fn normalize_observations( + input: &SccmClientDiscoveryInput, +) -> Result>, SccmClientDiscoveryError> { + let mut observations = BTreeMap::>::new(); + for observation in &input.observations { + let Some(normalized) = normalize_observation(observation) else { + continue; + }; + let key = PhysicalObservationKey { + root_handle: observation.root_handle.clone(), + canonical_basename: normalized.canonical_basename.clone(), + rotation: rotation_segment(&observation.rotation), + }; + 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); + } + } + } + } + let mut observations = observations.into_values().collect::>(); + observations.sort_by(compare_observation_order); + Ok(observations) +} + +fn normalize_observation( + observation: &SccmClientDiscoveryObservation, +) -> Option> { + #[cfg(test)] + NORMALIZATION_OPERATIONS.with(|count| count.set(count.get() + 1)); + root_handle_digest(&observation.root_handle)?; + let canonical_basename = canonical_client_source(&observation.basename, &observation.rotation)?; + let logical_artifact_ids = logical_artifact_ids(&canonical_basename); + Some(NormalizedObservation { + observation, + canonical_basename, + logical_artifact_ids, + }) +} + +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, + }) +} + +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, + ), + }; + 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, + } +} + +#[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 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)); + } + + #[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(); + 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" + ); + } + + #[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(); + 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!( + 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 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/mod.rs b/src-tauri/src/sccm/mod.rs index 9cd43a25a..335a74986 100644 --- a/src-tauri/src/sccm/mod.rs +++ b/src-tauri/src/sccm/mod.rs @@ -5,9 +5,11 @@ //! pure contracts without changing the generic collection manifest. 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/tests/sccm_client_discovery.rs b/src-tauri/tests/sccm_client_discovery.rs new file mode 100644 index 000000000..35993df0d --- /dev/null +++ b/src-tauri/tests/sccm_client_discovery.rs @@ -0,0 +1,691 @@ +use app_lib::sccm::{ + discover_client_sources, SccmClientDiscoveryError, SccmClientDiscoveryInput, + SccmClientDiscoveryObservation, SccmClientDiscoveryObservationState, SccmClientDiscoveryState, + MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS, +}; +use cmtraceopen_parser::sccm::SccmRotation; +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 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_evidence_identity( + canonical_basename: &str, + root_handle: &str, + rotation: &SccmRotation, + physical_basename: &str, +) -> String { + let catalog_entry_id = format!( + "sccm-client-source:v1:sha256:{}", + sha256(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_uses_one_global_declaration_budget_and_marks_the_first_omitted_rotation() { + 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 gap = result + .declarations + .last() + .expect("the globally capped result retains the first omitted declaration"); + assert_eq!(gap.basename, "PolicyAgent.log.2048"); + assert_eq!(gap.rotation, SccmRotation::Numbered(2_048)); + assert_eq!(gap.state, SccmClientDiscoveryState::Capped); + assert_eq!( + result + .declarations + .iter() + .filter(|declaration| declaration.state == SccmClientDiscoveryState::Discovered) + .count(), + MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS - 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_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_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_skips_malformed_roots_and_unsupported_basenames() { + let result = discover_client_sources(&SccmClientDiscoveryInput { + max_found_fragments_per_source: 8, + observations: vec![ + observation( + "root-not-a-sha256-handle", + "AppEnforce.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_B, + "Unrelated.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + observation( + ROOT_B, + "PolicyAgent.log", + SccmRotation::Current, + SccmClientDiscoveryObservationState::Found, + ), + ], + }) + .expect("malformed roots are skipped rather than becoming a discovery failure"); + + assert_eq!(result.declarations.len(), 1); + assert_eq!(result.declarations[0].root_handle, ROOT_B); + assert_eq!(result.declarations[0].basename, "PolicyAgent.log"); +} From 1bba619fe972f48c7526f7be4b9240725db6d6df Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:01:14 -0400 Subject: [PATCH 323/422] fix(sccm): preserve bounded discovery coverage correctness (#454) Preserve rejected and omitted SCCM client discovery coverage deterministically while keeping declarations bounded and privacy-safe. Reviewed with exact-head Copilot and CodeRabbit; focused, parser, strict Clippy, wasm32, TypeScript, Windows ESP, and cross-platform packaging gates passed. --- src-tauri/src/sccm/discovery.rs | 541 ++++++++++++++-- src-tauri/tests/sccm_client_discovery.rs | 750 ++++++++++++++++++++++- 2 files changed, 1214 insertions(+), 77 deletions(-) diff --git a/src-tauri/src/sccm/discovery.rs b/src-tauri/src/sccm/discovery.rs index 8786ba43b..378acf528 100644 --- a/src-tauri/src/sccm/discovery.rs +++ b/src-tauri/src/sccm/discovery.rs @@ -3,6 +3,7 @@ use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; +use std::num::NonZeroU16; #[cfg(test)] use std::cell::Cell; @@ -21,20 +22,70 @@ pub const MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS: usize = 4_096; /// 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)] +#[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)] @@ -69,6 +120,9 @@ pub struct SccmClientDiscoveryDeclaration { #[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)] @@ -99,17 +153,51 @@ struct Candidate { source_digest: String, } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct PhysicalObservationKey { - root_handle: String, +struct NormalizedObservation<'a> { + observation: &'a SccmClientDiscoveryObservation, canonical_basename: String, - rotation: String, + logical_artifact_ids: Vec, } -struct NormalizedObservation<'a> { - observation: &'a SccmClientDiscoveryObservation, +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)] @@ -118,6 +206,9 @@ thread_local! { 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( @@ -127,16 +218,18 @@ pub fn discover_client_sources( return Err(SccmClientDiscoveryError::ObservationLimitExceeded); } - let observations = normalize_observations(input)?; + 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 declarations = Vec::with_capacity( + let mut selected = Vec::with_capacity( input .observations .len() - .min(MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS), + .min(MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS), ); - let mut first_omitted: Option<(NormalizedObservation<'_>, SccmClientDiscoveryState)> = None; for observation in observations { let Some(state) = selection_state( @@ -147,78 +240,303 @@ pub fn discover_client_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); + } - if declarations.len() < MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS - 1 { - declarations.push(declaration_from_candidate( + let declarations = selected + .into_iter() + .map(|(observation, state)| { + declaration_from_candidate( candidate_from_observation(&observation).expect("prevalidated observation"), state, - )); - } else if let Some((first_omitted, _)) = first_omitted { - declarations.push(declaration_from_candidate( - candidate_from_observation(&first_omitted).expect("prevalidated observation"), - SccmClientDiscoveryState::Capped, - )); - return Ok(SccmClientDiscoveryResult { declarations }); - } else { - first_omitted = Some((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 } +} - if let Some((last, state)) = first_omitted { - declarations.push(declaration_from_candidate( - candidate_from_observation(&last).expect("prevalidated observation"), - state, - )); +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)) } +} - Ok(SccmClientDiscoveryResult { declarations }) +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 observations = BTreeMap::>::new(); +) -> 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 { - let Some(normalized) = normalize_observation(observation) else { - continue; + #[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 key = PhysicalObservationKey { - root_handle: observation.root_handle.clone(), - canonical_basename: normalized.canonical_basename.clone(), - rotation: rotation_segment(&observation.rotation), + let raw_identity = RawPhysicalIdentity { + root_handle: &observation.root_handle, + raw_basename: &observation.basename, }; - match observations.entry(key) { + match physical_facts.entry(raw_identity) { std::collections::btree_map::Entry::Vacant(entry) => { - entry.insert(normalized); + entry.insert(facts); } - std::collections::btree_map::Entry::Occupied(mut entry) => { - if entry.get().observation.state != observation.state { + std::collections::btree_map::Entry::Occupied(entry) => { + if *entry.get() != facts { return Err(SccmClientDiscoveryError::ConflictingObservation); } - if compare_observation_order(&normalized, entry.get()) == Ordering::Less { - entry.insert(normalized); + } + } + + 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); - Ok(observations) + debug_assert!(coverage_issue_counts.len() <= MAX_SCCM_CLIENT_DISCOVERY_COVERAGE_ISSUES); + Ok(NormalizedDiscovery { + observations, + coverage_issue_counts, + }) } -fn normalize_observation( - observation: &SccmClientDiscoveryObservation, -) -> Option> { - #[cfg(test)] - NORMALIZATION_OPERATIONS.with(|count| count.set(count.get() + 1)); - root_handle_digest(&observation.root_handle)?; - let canonical_basename = canonical_client_source(&observation.basename, &observation.rotation)?; - let logical_artifact_ids = logical_artifact_ids(&canonical_basename); - Some(NormalizedObservation { - observation, - canonical_basename, +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( @@ -245,6 +563,7 @@ fn selection_state( } SccmClientDiscoveryObservationState::AccessDenied => SccmClientDiscoveryState::AccessDenied, SccmClientDiscoveryObservationState::NotFound => SccmClientDiscoveryState::NotFound, + SccmClientDiscoveryObservationState::Skipped => SccmClientDiscoveryState::Skipped, }) } @@ -304,6 +623,13 @@ fn declaration_from_candidate( &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( @@ -394,6 +720,7 @@ fn state_rank(state: SccmClientDiscoveryObservationState) -> u8 { SccmClientDiscoveryObservationState::Found => 0, SccmClientDiscoveryObservationState::AccessDenied => 1, SccmClientDiscoveryObservationState::NotFound => 2, + SccmClientDiscoveryObservationState::Skipped => 3, } } @@ -417,6 +744,13 @@ mod tests { } } + 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), @@ -445,6 +779,35 @@ mod tests { 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) @@ -463,6 +826,8 @@ mod tests { reset_construction_counts(); reset_normalization_count(); + reset_consistency_key_count(); + reset_classification_work_counts(); assert_eq!( discover_client_sources(&input), Err(SccmClientDiscoveryError::ObservationLimitExceeded), @@ -478,6 +843,13 @@ mod tests { (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] @@ -507,6 +879,8 @@ mod tests { 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, @@ -518,6 +892,23 @@ mod tests { 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" @@ -551,6 +942,44 @@ mod tests { ); } + #[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(); diff --git a/src-tauri/tests/sccm_client_discovery.rs b/src-tauri/tests/sccm_client_discovery.rs index 35993df0d..5f9c78b8b 100644 --- a/src-tauri/tests/sccm_client_discovery.rs +++ b/src-tauri/tests/sccm_client_discovery.rs @@ -1,9 +1,12 @@ use app_lib::sccm::{ - discover_client_sources, SccmClientDiscoveryError, SccmClientDiscoveryInput, - SccmClientDiscoveryObservation, SccmClientDiscoveryObservationState, SccmClientDiscoveryState, - MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, MAX_SCCM_CLIENT_DISCOVERY_OBSERVATIONS, + 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; +use cmtraceopen_parser::sccm::{SccmRotation, SccmUnknownRotation}; +use serde_json::Value; use sha2::{Digest, Sha256}; const ROOT_A: &str = "root-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -23,6 +26,13 @@ fn observation( } } +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() @@ -86,16 +96,20 @@ fn expected_marker_artifact_id( ) } +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 = format!( - "sccm-client-source:v1:sha256:{}", - sha256(canonical_basename) - ); + 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:") @@ -110,7 +124,75 @@ fn expected_evidence_identity( } #[test] -fn discovery_uses_one_global_declaration_budget_and_marks_the_first_omitted_rotation() { +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( @@ -144,21 +226,32 @@ fn discovery_uses_one_global_declaration_budget_and_marks_the_first_omitted_rota MAX_SCCM_CLIENT_DISCOVERY_DECLARATIONS, "the 4096 declaration budget must be shared by all roots and sources" ); - let gap = result + let terminal = result .declarations .last() - .expect("the globally capped result retains the first omitted declaration"); - assert_eq!(gap.basename, "PolicyAgent.log.2048"); - assert_eq!(gap.rotation, SccmRotation::Numbered(2_048)); - assert_eq!(gap.state, SccmClientDiscoveryState::Capped); + .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 - 1 + 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] @@ -190,6 +283,196 @@ fn discovery_at_the_exact_global_boundary_does_not_manufacture_a_gap() { .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 { @@ -480,6 +763,72 @@ fn discovery_rejects_conflicting_states_for_one_canonical_physical_source() { 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(); @@ -659,12 +1008,14 @@ fn discovery_rejects_observations_beyond_its_defensive_contract() { } #[test] -fn discovery_skips_malformed_roots_and_unsupported_basenames() { - let result = discover_client_sources(&SccmClientDiscoveryInput { +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( - "root-not-a-sha256-handle", + malformed_root, "AppEnforce.log", SccmRotation::Current, SccmClientDiscoveryObservationState::Found, @@ -675,6 +1026,233 @@ fn discovery_skips_malformed_roots_and_unsupported_basenames() { 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", @@ -682,10 +1260,140 @@ fn discovery_skips_malformed_roots_and_unsupported_basenames() { 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("malformed roots are skipped rather than becoming a discovery failure"); + .expect("rejected candidate coverage is order independent"); + assert_eq!(result, reversed); assert_eq!(result.declarations.len(), 1); - assert_eq!(result.declarations[0].root_handle, ROOT_B); - assert_eq!(result.declarations[0].basename, "PolicyAgent.log"); + 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")); } From 09aafd4a70fb70f7db90a4ebb09c10bb1ee12020 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:46:06 -0400 Subject: [PATCH 324/422] fix(sccm): bind server coverage to canonical topology (#455) * test(parser): cover server coverage topology handles * fix(parser): bind server coverage to topology handles * test(parser): reject incongruent server coverage topology * fix(parser): enforce server coverage topology congruence * test(sccm): expose MP coverage handle seal gaps * fix(sccm): seal MP coverage topology handles * test(sccm): reject mutated site-core topology authority * fix(sccm): bind site-core to intake topology authority * test(sccm): reject forged site-core producer authority * fix(sccm): seal site-core intake authority * refactor(sccm): share topology authority normalization * refactor(sccm): centralize coverage identity keys * test(sccm): quarantine invalid intake authority * fix(sccm): quarantine invalid intake authority * refactor(sccm): clarify server authority quarantine * test(sccm): clarify server authority mutations * test(sccm): bind authority assertions to intake * test(sccm): keep authority exclusions nonvacuous --- CHANGELOG.md | 10 + .../src/sccm/server/windows/intake.rs | 154 +++- .../server/windows/management_point_tests.rs | 208 ++++- .../src/sccm/server/windows/site_core.rs | 118 +-- .../tests/sccm_server_intake.rs | 140 ++++ .../tests/sccm_server_site_core.rs | 743 +++++++++--------- .../preparation/issue-335-server-intake.md | 13 + 7 files changed, 929 insertions(+), 457 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dca96054..fd21abafb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### 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/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 08bcb3d42..d47ca5b92 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -194,6 +194,26 @@ impl SccmServerIntakeAssessment { &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 @@ -216,6 +236,23 @@ impl SccmServerIntakeAssessment { } } +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; + } + 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. @@ -226,7 +263,7 @@ struct SccmServerIntakeIntegrity { structure: IntakeIntegrityStructure, topology: IntakeIntegrityRecord, artifacts: BTreeMap, - coverage: BTreeMap, + coverage: BTreeMap, evidence: BTreeMap, } @@ -254,13 +291,57 @@ struct IntakeIntegrityStructure { struct ArtifactIntegrityIdentity(String); #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct CoverageIntegrityIdentity { +pub(super) struct CoverageIdentityKey { producer_role: String, - workflow_subject_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); @@ -298,8 +379,16 @@ impl SccmServerIntakeIntegrity { .iter() .map(|(identity, record)| { identity.producer_role.len() - + identity.workflow_subject_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() @@ -474,11 +563,25 @@ 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, @@ -592,8 +695,7 @@ pub fn assess_server_intake( let mut artifacts = Vec::with_capacity(prepared.len()); let mut evidence = Vec::new(); - let mut coverage_by_key: BTreeMap<(String, String, String, String), SccmServerCoverage> = - BTreeMap::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 @@ -606,23 +708,15 @@ pub fn assess_server_intake( for prepared_artifact in prepared { let artifact = prepared_artifact.assessment; - let coverage_key = ( - role_sort_key(&artifact.producer_role).to_owned(), - artifact.source_id.clone(), - artifact - .workflow_subject_role - .as_ref() - .map(role_sort_key) - .unwrap_or_default() - .to_owned(), - coverage_sort_key(&artifact.state).to_owned(), - ); + 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()], @@ -1310,10 +1404,12 @@ 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 { @@ -1433,17 +1529,7 @@ fn canonical_intake_integrity_with_structure( #[cfg(test)] INTAKE_CANONICALIZATION_CALLS.with(|calls| calls.set(calls.get().saturating_add(1))); - let mut normalized_topology = topology.clone(); - normalized_topology - .roles_observed - .sort_by(|left, right| role_sort_key(left).cmp(role_sort_key(right))); - if normalized_topology - .roles_observed - .windows(2) - .any(|roles| roles[0] == roles[1]) - { - return None; - } + let normalized_topology = normalized_topology_or_none(topology)?; let mut normalized_coverage = coverage.to_vec(); for record in &mut normalized_coverage { @@ -1484,17 +1570,7 @@ fn canonical_intake_integrity_with_structure( let mut coverage_integrity = BTreeMap::new(); for record in &normalized_coverage { - let identity = CoverageIntegrityIdentity { - producer_role: role_sort_key(&record.producer_role).to_owned(), - workflow_subject_role: record - .workflow_subject_role - .as_ref() - .map(role_sort_key) - .unwrap_or_default() - .to_owned(), - source_id: record.source_id.clone(), - state: coverage_sort_key(&record.state).to_owned(), - }; + let identity = CoverageIdentityKey::from_coverage(record); let max_payload_len = match expected { Some(expected) => Some(expected.coverage.get(&identity)?.payload_len), None => None, 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 index 7cbb63b5f..5f301867f 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/management_point_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/management_point_tests.rs @@ -188,11 +188,23 @@ fn load_bundle(scenario: &str) -> SccmManagementPointBundle { fn load_server_intake_fixture( directory: &Path, ) -> cmtraceopen_parser::sccm::server::windows::SccmServerIntakeAssessment { - let manifest_json = fs::read_to_string(directory.join("manifest.json")) - .expect("server intake fixture manifest must be readable"); - let manifest: Value = serde_json::from_str(&manifest_json) - .expect("server intake fixture manifest must be valid JSON"); - let payloads = manifest["artifacts"] + 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() @@ -208,8 +220,11 @@ fn load_server_intake_fixture( }) }) .collect::>(); - assess_server_intake(&manifest_json, &payloads) - .expect("fixture must satisfy canonical server intake") + 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( @@ -822,6 +837,18 @@ fn canonical_intake_adapter_accepts_reordered_authoritative_records() { 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"); @@ -836,6 +863,173 @@ fn canonical_intake_adapter_accepts_reordered_authoritative_records() { ); } +#[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 = diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs index 7347eeb3c..d7b9b3513 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/site_core.rs @@ -18,6 +18,7 @@ use crate::sccm::{ SccmTimeOrderingState, SccmTimestamp, }; +use super::intake::CoverageIdentityKey; use super::{ SccmServerArtifactAssessment, SccmServerConfiguredPathState, SccmServerIntakeAssessment, }; @@ -32,6 +33,9 @@ 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, @@ -296,6 +300,7 @@ struct AdmittedSource<'a> { 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, @@ -303,12 +308,38 @@ struct SiteCoreContext<'a> { 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); - let coverage_congruent = site_core_coverage_is_congruent(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, @@ -321,6 +352,7 @@ impl<'a> SiteCoreContext<'a> { Self { artifacts: &intake.artifacts, sources, + intake_authority_is_bound, evidence_identity_is_unique, coverage_gaps, coverage_gap_producer_hosts: BTreeMap::new(), @@ -388,35 +420,37 @@ pub fn analyze_site_core(intake: &SccmServerIntakeAssessment) -> SccmSiteCoreAna let mut context = SiteCoreContext::new(intake); let mut grouped = BTreeMap::>::new(); let mut record_observations = Vec::new(); - 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, - )); + 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; } - 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); + if !source.fact_eligible || !context.evidence_identity_is_unique[position] { + continue; } - ProfileRecordParse::Rejected(reason_code) => { - record_observations.push(rejected_record_observation( - evidence, - source, - reason_code, - )); + 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 => {} } - ProfileRecordParse::NotCandidate => {} } } context.add_undeclared_peer_source_gaps(&grouped); @@ -1934,46 +1968,24 @@ fn evidence_collision_artifact_ids( } fn site_core_coverage_is_congruent(intake: &SccmServerIntakeAssessment) -> bool { - type CoverageKey = (String, String, String, String); - - let mut expected = BTreeMap::>::new(); + let mut expected = BTreeMap::>::new(); for artifact in &intake.artifacts { if SiteCoreGroup::from_source_id(&artifact.source_id).is_none() { continue; } expected - .entry(( - role_sort_key(&artifact.producer_role).to_owned(), - artifact - .workflow_subject_role - .as_ref() - .map(role_sort_key) - .unwrap_or_default() - .to_owned(), - artifact.source_id.clone(), - coverage_sort_key(&artifact.state).to_owned(), - )) + .entry(CoverageIdentityKey::from_artifact(artifact)) .or_default() .push(artifact.artifact_id.clone()); } - let mut observed = BTreeMap::>::new(); + let mut observed = BTreeMap::>::new(); for coverage in &intake.coverage { if SiteCoreGroup::from_source_id(&coverage.source_id).is_none() { continue; } observed - .entry(( - role_sort_key(&coverage.producer_role).to_owned(), - coverage - .workflow_subject_role - .as_ref() - .map(role_sort_key) - .unwrap_or_default() - .to_owned(), - coverage.source_id.clone(), - coverage_sort_key(&coverage.state).to_owned(), - )) + .entry(CoverageIdentityKey::from_coverage(coverage)) .or_default() .extend(coverage.artifact_ids.iter().cloned()); } diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index cb0f5aaa9..ba12f3517 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -1057,6 +1057,58 @@ fn server_intake_scopes_canonical_identity_to_producer_host() { ); } +#[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"); @@ -1137,6 +1189,94 @@ fn server_intake_scopes_canonical_identity_to_workflow_subject() { ); } +#[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"); diff --git a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs index 4cfaaeb88..98081b87e 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_site_core.rs @@ -267,6 +267,26 @@ impl<'a> Source<'a> { } 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, @@ -278,7 +298,7 @@ fn assess(sources: &[Source<'_>]) -> SccmServerIntakeAssessment { "siteCode": "LAB", "rolesObserved": ["siteServer"], }, - "artifacts": sources.iter().map(Source::manifest_artifact).collect::>(), + "artifacts": artifacts, }); let payloads = sources .iter() @@ -319,6 +339,25 @@ fn replace_source_artifact_id( } } +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()); @@ -352,148 +391,59 @@ fn assert_bounded_request_has_specific_scope(request: &SccmSiteCoreArtifactReque ); } -fn assert_malformed_peer_source_fails_closed( +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, - malformed_id: &str, - required_source_id: &str, - required_reason_code: &str, + intake: &SccmServerIntakeAssessment, ) { - 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()); - - let synthetic_gap = analysis - .coverage_gaps + // 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() - .find(|gap| { - gap.source_id == required_source_id - && gap.state == SccmCoverageState::Absent - && gap.reason_code == required_reason_code + .map(|artifact| { + ( + artifact.artifact_id.as_str(), + artifact.source_id.as_str(), + artifact.producer_host_handle.as_deref(), + ) }) - .expect("ineligible peer leaves a synthetic missing-source gap"); - assert!(synthetic_gap - .artifact_id - .starts_with("site-core:missing-source:v1:")); - assert_eq!( - result.coverage_gap_artifact_ids, - vec![synthetic_gap.artifact_id.clone()] - ); + .collect::>(); + assert_invalid_authority_excludes_source_triples(analysis, &source_triples); +} - let rejected_gap = analysis - .coverage_gaps - .iter() - .find(|gap| { - gap.source_id == required_source_id - && gap.state == SccmCoverageState::ParseFailed - && gap.reason_code == "evidence-reference-rejected" - }) - .expect("malformed peer remains explicit rejected coverage"); - assert!(rejected_gap - .artifact_id - .starts_with("site-core:rejected-artifact:v1:")); - assert_ne!(rejected_gap.artifact_id, malformed_id); - assert!(analysis.coverage_gaps.iter().all(|gap| { - gap.artifact_id != malformed_id - && !gap.artifact_id.is_empty() - && gap.artifact_id.len() <= 256 - && gap.artifact_id.trim() == gap.artifact_id - && gap.artifact_id.bytes().all(|byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b':' | b'_' | b'-') - }) - })); +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"); - let result_finding = analysis - .findings - .iter() - .find(|finding| finding.subject_id == result.result_id) - .expect("malformed peer retains a validated result finding"); + assert_eq!(analysis.unlinked_observations.len(), 1); + let observation = &analysis.unlinked_observations[0]; assert_eq!( - result_finding.finding.class, + observation.finding_class, SccmFindingClass::InsufficientEvidence ); - assert_eq!(result_finding.finding.coverage_gaps.len(), 1); assert_eq!( - result_finding.finding.coverage_gaps[0].artifact_id, - synthetic_gap.artifact_id + observation.coverage_gap_artifact_ids, + ["site-core-intake-authority"] ); + assert!(observation.evidence.is_empty()); + assert!(observation.next_artifacts.is_empty()); - for gap in &analysis.coverage_gaps { - let observation = analysis - .unlinked_observations - .iter() - .find(|observation| observation.coverage_gap_artifact_ids == [gap.artifact_id.clone()]) - .expect("each gap has an explicit coverage observation"); - let finding = analysis - .findings - .iter() - .find(|finding| finding.subject_id == observation.observation_id) - .expect("each gap has a validated coverage finding"); - assert_eq!( - finding.finding.class, - SccmFindingClass::InsufficientEvidence - ); - assert!(finding - .finding - .coverage_gaps - .iter() - .any(|finding_gap| finding_gap.artifact_id == gap.artifact_id)); - } - - let requests = analysis - .artifact_requests - .iter() - .filter(|request| request.logical_name == required_source_id) - .collect::>(); - assert!(!requests.is_empty()); - for request in requests { - assert_bounded_request_has_specific_scope(request); - assert_eq!( - request.scope.producer_host_handle.as_deref(), - Some("synthetic:host:site-01") - ); - } + assert!(analysis.findings.is_empty()); + assert!(analysis.artifact_requests.is_empty()); assert!(!analysis.cross_side_correlation_performed); } -fn assert_explicit_gap_and_request( - analysis: &SccmSiteCoreAnalysis, - artifact_id: &str, - source_id: &str, -) { - assert!(analysis.coverage_gaps.iter().any(|gap| { - gap.artifact_id == artifact_id && gap.state == SccmCoverageState::ParseFailed - })); - assert!(analysis.unlinked_observations.iter().any(|observation| { - observation.finding_class == SccmFindingClass::InsufficientEvidence - && observation - .coverage_gap_artifact_ids - .iter() - .any(|candidate| candidate == artifact_id) - })); - let request = analysis - .artifact_requests - .iter() - .find(|request| request.logical_name == source_id) - .expect("coverage gap has a source-specific artifact request"); - assert_bounded_request_has_specific_scope(request); - for request in &analysis.artifact_requests { - assert_bounded_request_has_specific_scope(request); - } -} - -fn assert_gap_reason(analysis: &SccmSiteCoreAnalysis, artifact_id: &str, reason_code: &str) { - assert!(analysis.coverage_gaps.iter().any(|gap| { - gap.artifact_id == artifact_id - && gap.state == SccmCoverageState::ParseFailed - && gap.reason_code == reason_code - })); -} - fn assert_delimiter_attached_unknown_label_fails_closed(delimiter: char) { for (position, outcome_field) in [ ( @@ -793,16 +743,13 @@ fn unrelated_same_minute_components_and_producer_hosts_never_merge() { .iter() .any(|result| result.state == SccmSiteCoreState::TerminalFailure)); - let mut split_hosts = assess(&[ - Source::sitecomp(HEALTHY_SITECOMP), - Source::status(HEALTHY_STATUS), - ]); - split_hosts - .artifacts - .iter_mut() - .find(|artifact| artifact.source_id == "server-status") - .expect("status artifact") - .producer_host_handle = Some("synthetic:host:site-02".to_owned()); + 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!( @@ -820,15 +767,12 @@ fn unrelated_same_minute_components_and_producer_hosts_never_merge() { assert!(split .results .iter() - .any(|result| { result.transaction_key.producer_host_handle == "synthetic:host:site-02" })); + .any(|result| { result.transaction_key.producer_host_handle == "synthetic:host:mp-01" })); - let mut foreign_gap = assess(&[Source::sitecomp(HEALTHY_SITECOMP), Source::absent_status()]); - foreign_gap - .artifacts - .iter_mut() - .find(|artifact| artifact.source_id == "server-status") - .expect("status artifact") - .producer_host_handle = Some("synthetic:host:site-02".to_owned()); + 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!( @@ -915,15 +859,7 @@ fn encoding_profile_coverage_fragment_cap_and_time_provenance_fail_closed() { evidence.timestamp.ordering_state = SccmTimeOrderingState::OffsetInvalid; } - for (name, assessment) in [ - ("encoding", encoding), - ("profile", unknown_profile), - ("coverage", denied), - ("fragment", incomplete_fragment), - ("content", missing_content_provenance), - ("cap", capped), - ("time", invalid_time), - ] { + for (name, assessment) in [("encoding", encoding), ("cap", capped)] { let analysis = analyze_site_core(&assessment); assert!( !analysis.coverage_gaps.is_empty(), @@ -953,6 +889,16 @@ fn encoding_profile_coverage_fragment_cap_and_time_provenance_fail_closed() { || 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] @@ -1152,12 +1098,7 @@ fn malformed_status_peer_cannot_hide_required_status_coverage() { ]); replace_source_artifact_id(&mut assessment, "server-status", &malformed_id); - assert_malformed_peer_source_fails_closed( - &analyze_site_core(&assessment), - &malformed_id, - "server-status", - "required-status-source-not-declared", - ); + assert_malformed_peer_source_fails_closed(&analyze_site_core(&assessment), &malformed_id); } } @@ -1170,12 +1111,7 @@ fn malformed_component_peer_cannot_hide_required_component_coverage() { ]); replace_source_artifact_id(&mut assessment, "server-sitecomp", &malformed_id); - assert_malformed_peer_source_fails_closed( - &analyze_site_core(&assessment), - &malformed_id, - "server-sitecomp", - "required-component-source-not-declared", - ); + assert_malformed_peer_source_fails_closed(&analyze_site_core(&assessment), &malformed_id); } } @@ -1206,16 +1142,13 @@ fn undeclared_component_gap_is_deterministic_under_status_only_assessment_permut #[test] fn undeclared_component_gap_does_not_attach_across_producer_hosts() { - let mut assessment = assess(&[ - Source::status(HEALTHY_STATUS), - Source::sitecomp(HEALTHY_SITECOMP), - ]); - assessment - .artifacts - .iter_mut() - .find(|artifact| artifact.source_id == "server-sitecomp") - .expect("component artifact") - .producer_host_handle = Some("synthetic:host:site-02".to_owned()); + 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 @@ -1242,7 +1175,7 @@ fn undeclared_component_gap_does_not_attach_across_producer_hosts() { let foreign_component_result = analysis .results .iter() - .find(|result| result.transaction_key.producer_host_handle == "synthetic:host:site-02") + .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 @@ -1281,22 +1214,11 @@ fn no_provenance_mutation_can_reintroduce_a_confirmed_failure() { .expect("captured source provenance") .limit_applied = true; - let analysis = analyze_site_core(&assessment); - assert!(analysis.results.is_empty()); - assert!(analysis - .coverage_gaps - .iter() - .any(|gap| gap.artifact_id == "sitecomp-current")); - let request = analysis - .artifact_requests - .iter() - .find(|request| request.logical_name == "server-sitecomp") - .expect("provenance coverage gap has a request"); - assert_bounded_request_has_specific_scope(request); + assert_authority_invalid_analysis(&analyze_site_core(&assessment)); } #[test] -fn rejected_role_subject_and_duplicate_sources_become_explicit_parse_gaps() { +fn post_intake_source_contract_mutations_fail_sealed_authority_closed() { let healthy = assess(&[ Source::sitecomp(HEALTHY_SITECOMP), Source::status(HEALTHY_STATUS), @@ -1309,11 +1231,7 @@ fn rejected_role_subject_and_duplicate_sources_become_explicit_parse_gaps() { .find(|artifact| artifact.source_id == "server-status") .expect("status artifact") .producer_role = SccmRole::ManagementPoint; - assert_explicit_gap_and_request( - &analyze_site_core(&wrong_role), - "z-site-status", - "server-status", - ); + assert_authority_invalid_analysis(&analyze_site_core(&wrong_role)); let mut wrong_subject = healthy.clone(); let sitecomp = wrong_subject @@ -1323,10 +1241,18 @@ fn rejected_role_subject_and_duplicate_sources_become_explicit_parse_gaps() { .expect("sitecomp artifact"); sitecomp.workflow_subject_role = Some(SccmRole::Client); sitecomp.workflow_subject_handle = Some("synthetic:subject:client-01".to_owned()); - assert_explicit_gap_and_request( - &analyze_site_core(&wrong_subject), - "sitecomp-current", - "server-sitecomp", + 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; @@ -1337,11 +1263,7 @@ fn rejected_role_subject_and_duplicate_sources_become_explicit_parse_gaps() { .expect("sitecomp artifact") .clone(); duplicate.artifacts.push(duplicate_sitecomp); - assert_explicit_gap_and_request( - &analyze_site_core(&duplicate), - "sitecomp-current", - "server-sitecomp", - ); + assert_authority_invalid_analysis(&analyze_site_core(&duplicate)); let mut rejected_shape = assess(&[ Source::sitecomp(HEALTHY_SITECOMP), @@ -1353,15 +1275,11 @@ fn rejected_role_subject_and_duplicate_sources_become_explicit_parse_gaps() { .find(|artifact| artifact.source_id == "server-status") .expect("status artifact") .original_basename = Some("future-status.bin".to_owned()); - assert_explicit_gap_and_request( - &analyze_site_core(&rejected_shape), - "z-site-status", - "server-status", - ); + assert_authority_invalid_analysis(&analyze_site_core(&rejected_shape)); } #[test] -fn rejected_evidence_contracts_become_source_gaps_with_scoped_requests() { +fn post_intake_evidence_mutations_fail_sealed_authority_closed() { let healthy = assess(&[ Source::sitecomp(HEALTHY_SITECOMP), Source::status(HEALTHY_STATUS), @@ -1369,78 +1287,34 @@ fn rejected_evidence_contracts_become_source_gaps_with_scoped_requests() { let mut wrong_role = healthy.clone(); wrong_role.evidence[0].role = SccmRole::ManagementPoint; - let analysis = analyze_site_core(&wrong_role); - assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); - assert_gap_reason(&analysis, "sitecomp-current", "evidence-role-rejected"); - assert!(analysis.unlinked_observations.iter().any(|observation| { - observation.finding_class == SccmFindingClass::Symptom - && observation - .evidence - .iter() - .any(|evidence| evidence.entry_id == wrong_role.evidence[0].evidence_id) - })); - assert_eq!(analysis.results.len(), 1); - assert!(analysis.results.iter().all(|result| { - result.state != SccmSiteCoreState::Healthy - || result.confidence != SccmSiteCoreConfidence::High - })); + 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; - let analysis = analyze_site_core(&incomplete_reference); - assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); - assert_gap_reason(&analysis, "sitecomp-current", "evidence-reference-rejected"); - assert!(analysis.unlinked_observations.iter().any(|observation| { - observation.finding_class == SccmFindingClass::Symptom && observation.evidence.is_empty() - })); - assert_eq!(analysis.results.len(), 1); - assert!(analysis.results.iter().all(|result| { - result.state != SccmSiteCoreState::Healthy - || result.confidence != SccmSiteCoreConfidence::High - })); + 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); - let analysis = analyze_site_core(&cross_source_reference); - assert_explicit_gap_and_request(&analysis, "z-site-status", "server-status"); - assert_gap_reason( - &analysis, - "z-site-status", - "evidence-source-attribution-rejected", + assert_intake_authority_mutation_fails_closed( + &analyze_site_core(&cross_source_reference), + &cross_source_reference, ); - assert!(analysis.unlinked_observations.iter().any(|observation| { - observation.finding_class == SccmFindingClass::Symptom - && observation - .evidence - .iter() - .any(|evidence| evidence.entry_id == cross_source_reference.evidence[0].evidence_id) - })); - assert_eq!(analysis.results.len(), 1); - assert!(analysis.results.iter().all(|result| { - result.state != SccmSiteCoreState::Healthy - || result.confidence != SccmSiteCoreConfidence::High - })); let mut unresolved_reference = healthy; unresolved_reference.evidence[0].reference.artifact_id = "orphan-sitecomp-record".to_owned(); - let analysis = analyze_site_core(&unresolved_reference); - assert_explicit_gap_and_request(&analysis, "orphan-sitecomp-record", "server-sitecomp"); - assert_gap_reason( - &analysis, - "orphan-sitecomp-record", - "evidence-source-unresolved", + assert_intake_authority_mutation_fails_closed( + &analyze_site_core(&unresolved_reference), + &unresolved_reference, ); - assert_eq!(analysis.results.len(), 1); - assert!(analysis.results.iter().all(|result| { - result.state != SccmSiteCoreState::Healthy - || result.confidence != SccmSiteCoreConfidence::High - })); } #[test] -fn foreign_artifact_identity_cannot_scope_an_unresolved_site_core_request() { +fn foreign_post_intake_artifact_identity_cannot_scope_a_site_core_request() { let mut assessment = assess(&[ Source::sitecomp(HEALTHY_SITECOMP), Source::status(HEALTHY_STATUS), @@ -1454,35 +1328,17 @@ fn foreign_artifact_identity_cannot_scope_an_unresolved_site_core_request() { assessment.evidence[0].reference.artifact_id = "foreign-artifact".to_owned(); let analysis = analyze_site_core(&assessment); - let gap = analysis - .coverage_gaps - .iter() - .find(|gap| { - gap.source_id == "server-sitecomp" && gap.reason_code == "evidence-source-unresolved" - }) - .expect("foreign attribution becomes a site-core coverage gap"); - assert_ne!(gap.artifact_id, "foreign-artifact"); - assert!(gap - .artifact_id - .starts_with("site-core:rejected-artifact:v1:")); - let request = analysis - .artifact_requests - .iter() - .find(|request| request.logical_name == "server-sitecomp") - .expect("unresolved site-core evidence has a bounded request"); - assert_bounded_request_has_specific_scope(request); - assert_eq!( - request.scope.producer_host_handle.as_deref(), - Some("synthetic:host:site-01") - ); - assert_ne!( - request.scope.rotation_lineage_handle.as_deref(), - Some("foreign-lineage") - ); + 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 rejected_nonprofile_prose_is_coverage_not_a_profile_symptom() { +fn post_intake_nonprofile_role_mutation_fails_sealed_authority_closed() { let mut assessment = assess(&[ Source::sitecomp(HEALTHY_SITECOMP), Source::status(HEALTHY_STATUS), @@ -1490,39 +1346,28 @@ fn rejected_nonprofile_prose_is_coverage_not_a_profile_symptom() { assessment.evidence[0].message = "ordinary non-profile source prose".to_owned(); assessment.evidence[0].role = SccmRole::ManagementPoint; - let analysis = analyze_site_core(&assessment); - assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); - assert_gap_reason(&analysis, "sitecomp-current", "evidence-role-rejected"); - assert_eq!(analysis.unlinked_observations.len(), 1); - assert_eq!( - analysis.unlinked_observations[0].finding_class, - SccmFindingClass::InsufficientEvidence - ); - assert!(analysis.unlinked_observations[0].evidence.is_empty()); + assert_intake_authority_mutation_fails_closed(&analyze_site_core(&assessment), &assessment); } #[test] -fn colliding_evidence_identities_are_parse_gaps_not_silent_drops() { +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); - let analysis = analyze_site_core(&assessment); - assert_explicit_gap_and_request(&analysis, "sitecomp-current", "server-sitecomp"); - assert!(analysis.results.is_empty()); + assert_authority_invalid_analysis(&analyze_site_core(&assessment)); } #[test] fn closed_profile_schema_rejects_arbitrary_keys_and_retains_safe_unknown_facts() { - let mut arbitrary_work = assess(&[ - Source::sitecomp(HEALTHY_SITECOMP), - Source::status(HEALTHY_STATUS), + 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), ]); - for evidence in &mut arbitrary_work.evidence { - evidence.message = evidence - .message - .replace("workItemId=SC-HEALTH-001", "workItemId=ARBITRARY-001"); - } let arbitrary = analyze_site_core(&arbitrary_work); assert!(arbitrary.results.is_empty()); assert_eq!( @@ -1535,14 +1380,13 @@ fn closed_profile_schema_rejects_arbitrary_keys_and_retains_safe_unknown_facts() .iter() .all(|evidence| arbitrary_wire.contains(&evidence.evidence_id))); - let mut unknown_status = assess(&[ - Source::sitecomp(HEALTHY_SITECOMP), + 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(); - unknown_status.evidence[0].message = unknown_status.evidence[0] - .message - .replace("SC_COMPONENT_START_OK", "SC_UNREVIEWED_STATUS"); let unknown = analyze_site_core(&unknown_status); let unknown_wire = serde_json::to_string(&unknown).expect("analysis serializes"); assert!(unknown_wire.contains(&rejected_id)); @@ -1622,28 +1466,17 @@ fn delimiter_separated_known_profile_labels_and_safe_prose_remain_accepted() { #[test] fn every_required_source_coverage_state_emits_insufficient_evidence_and_a_request() { - for state in [ - SccmCoverageState::Absent, - SccmCoverageState::AccessDenied, - SccmCoverageState::Skipped, - SccmCoverageState::Unsupported, + for (state_token, state) in [ + ("absent", SccmCoverageState::Absent), + ("accessDenied", SccmCoverageState::AccessDenied), + ("skipped", SccmCoverageState::Skipped), + ("unsupported", SccmCoverageState::Unsupported), ] { - let mut assessment = assess(&[ - Source::sitecomp(HEALTHY_SITECOMP), - Source::status(HEALTHY_STATUS), - ]); - assessment - .artifacts - .iter_mut() - .find(|artifact| artifact.source_id == "server-sitecomp") - .expect("sitecomp artifact") - .state = state.clone(); - assessment - .coverage - .iter_mut() - .find(|coverage| coverage.source_id == "server-sitecomp") - .expect("sitecomp coverage") - .state = state.clone(); + 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 @@ -1720,9 +1553,10 @@ fn invalid_finding_inputs_become_explicit_gaps_instead_of_clearing_class() { } let analysis = analyze_site_core(&assessment); - assert!(analysis.results.is_empty()); - assert!(!analysis.unlinked_observations.is_empty()); - assert!(!analysis.artifact_requests.is_empty()); + assert_authority_invalid_analysis(&analysis); + assert!(!serde_json::to_string(&analysis) + .expect("analysis serializes") + .contains(&oversized_id)); } #[test] @@ -1789,12 +1623,7 @@ fn rotation_provenance_must_match_classification_and_requests_use_exact_pairs() .expect("sitecomp artifact") .rotation = Some(SccmRotation::LoUnderscore); let rejected = analyze_site_core(&mismatch); - assert_explicit_gap_and_request(&rejected, "sitecomp-current", "server-sitecomp"); - assert_eq!(rejected.results.len(), 1); - assert!(rejected.results.iter().all(|result| { - result.state != SccmSiteCoreState::Healthy - || result.confidence != SccmSiteCoreConfidence::High - })); + assert_intake_authority_mutation_fails_closed(&rejected, &mismatch); let backlog = analyze_site_core(&assess(&[ Source::sitecomp(INBOX_BACKLOG), @@ -1829,11 +1658,7 @@ fn rotation_provenance_must_match_classification_and_requests_use_exact_pairs() kind: "future".to_owned(), value: None, })); - let unknown = analyze_site_core(&unknown_rotation); - assert!(!unknown.artifact_requests.is_empty()); - for request in &unknown.artifact_requests { - assert_bounded_request_has_specific_scope(request); - } + assert_authority_invalid_analysis(&analyze_site_core(&unknown_rotation)); } #[test] @@ -1845,8 +1670,210 @@ fn intake_coverage_must_be_congruent_before_facts_can_shape_results() { assessment.coverage.clear(); let analysis = analyze_site_core(&assessment); - assert!(analysis.results.is_empty()); - assert!(!analysis.coverage_gaps.is_empty()); - assert!(!analysis.unlinked_observations.is_empty()); - assert!(!analysis.artifact_requests.is_empty()); + 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/docs/sccm/preparation/issue-335-server-intake.md b/docs/sccm/preparation/issue-335-server-intake.md index 4c2551352..66ca37c08 100644 --- a/docs/sccm/preparation/issue-335-server-intake.md +++ b/docs/sccm/preparation/issue-335-server-intake.md @@ -158,6 +158,19 @@ Deferred native tests must make the write/privacy boundaries observable: ## 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, From 11250895e7d16eb37468c6e7fa35a340fd07c6fe Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 11:19:07 -0400 Subject: [PATCH 325/422] test(sccm): require sealed client evidence admission --- .../src/sccm/client/admission_tests.rs | 203 ++++++++++++++++++ .../cmtraceopen-parser/src/sccm/client/mod.rs | 3 + 2 files changed, 206 insertions(+) create mode 100644 crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs 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..86f75cff1 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs @@ -0,0 +1,203 @@ +use sha2::{Digest, Sha256}; + +use super::{ + admit_client_evidence, assess_client_intake, SccmClientCapturedPayload, + SccmClientIntakeArtifact, SccmClientIntakeBundle, +}; +use crate::sccm::{SccmArtifact, SccmCoverageState, SccmRole, SccmRotation}; + +fn digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn bundle() -> SccmClientIntakeBundle { + SccmClientIntakeBundle { + artifacts: vec![artifact("policy-agent", "PolicyAgent.log")], + capture_gaps: Vec::new(), + } +} + +fn artifact(identity: &str, basename: &str) -> 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-admitted-{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-admitted-{identity}")), + rotation_lineage: None, + relative_path: Some(format!("evidence/{group}/current/{basename}")), + fragment_complete: Some(true), + } +} + +fn payload() -> SccmClientCapturedPayload { + payload_for("fixture-admitted-policy-agent", "+000") +} + +fn payload_for(artifact_id: &str, offset: &str) -> SccmClientCapturedPayload { + let bytes = concat!( + "", + "\n" + ); + let bytes = bytes.into_bytes(); + SccmClientCapturedPayload { + artifact_id: artifact_id.to_owned(), + byte_length: bytes.len() as u64, + expected_sha256: digest(&bytes), + bytes, + } +} + +#[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().len(), 1); + assert!(admitted.verify_integrity().is_ok()); + assert_eq!( + admitted.source_coverage("client-policy-agent"), + Some(&SccmCoverageState::Captured) + ); +} + +#[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"); + let agent = payload(); + let state = payload_for("fixture-admitted-policy-state", "+000"); + + assert!(admit_client_evidence(&bundle, &assessment, &[agent.clone()]).is_err()); + + let mut extra = vec![agent.clone(), state.clone()]; + extra.push(payload_for("fixture-not-in-bundle", "+000")); + assert!(admit_client_evidence(&bundle, &assessment, &extra).is_err()); + + assert!(admit_client_evidence(&bundle, &assessment, &[agent.clone(), agent]).is_err()); + + let mut swapped = state; + swapped.artifact_id = "fixture-admitted-policy-agent".to_owned(); + assert!(admit_client_evidence(&bundle, &assessment, &[payload(), swapped]).is_err()); +} + +#[test] +fn admission_rejects_payload_digest_and_length_mismatches() { + let bundle = bundle(); + let assessment = assess_client_intake(&bundle).expect("fixture assessment is canonical"); + + let mut bad_digest = payload(); + bad_digest.expected_sha256 = "0".repeat(64); + assert!(admit_client_evidence(&bundle, &assessment, &[bad_digest]).is_err()); + + let mut bad_length = payload(); + bad_length.byte_length += 1; + assert!(admit_client_evidence(&bundle, &assessment, &[bad_length]).is_err()); +} + +#[test] +fn admission_rejects_noncaptured_incomplete_malformed_and_invalid_offset_payloads() { + let mut capped = bundle(); + capped.artifacts[0].artifact.coverage = SccmCoverageState::Capped; + capped.artifacts[0].fragment_complete = Some(false); + 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); + let incomplete_assessment = + assess_client_intake(&incomplete).expect("incomplete boundary is explicit"); + assert!(admit_client_evidence(&incomplete, &incomplete_assessment, &[payload()]).is_err()); + + let malformed = SccmClientCapturedPayload { + artifact_id: "fixture-admitted-policy-agent".to_owned(), + bytes: b"not a CCM logical record".to_vec(), + byte_length: 24, + expected_sha256: digest(b"not a CCM logical record"), + }; + let assessment = assess_client_intake(&bundle()).expect("fixture assessment is canonical"); + assert!(admit_client_evidence(&bundle(), &assessment, &[malformed]).is_err()); + + assert!(admit_client_evidence(&bundle(), &assessment, &[payload_for( + "fixture-admitted-policy-agent", + "+9999", + )]) + .is_err()); +} + +#[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[0].fragments.clear(); + assert!(admit_client_evidence(&bundle, &forged, &[payload(), payload_for( + "fixture-admitted-policy-state", + "+000", + )]) + .is_err()); + + let forward = admit_client_evidence( + &bundle, + &canonical, + &[payload(), payload_for("fixture-admitted-policy-state", "+000")], + ) + .expect("forward payload ordering is admitted"); + let reverse = admit_client_evidence( + &bundle, + &canonical, + &[payload_for("fixture-admitted-policy-state", "+000"), payload()], + ) + .expect("reverse payload ordering is admitted"); + assert_eq!(forward.evidence(), reverse.evidence()); + 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()); + + 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()); + + 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/mod.rs b/crates/cmtraceopen-parser/src/sccm/client/mod.rs index dd43bd011..5af403fb4 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -5,4 +5,7 @@ mod intake; +#[cfg(test)] +mod admission_tests; + pub use intake::*; From c863233dac5434ab0e2e270fa764602864733f3c Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 11:29:21 -0400 Subject: [PATCH 326/422] feat(sccm): seal canonical client evidence admission --- .../src/sccm/client/admission.rs | 378 ++++++++++++++++++ .../src/sccm/client/admission_tests.rs | 150 +++++-- .../cmtraceopen-parser/src/sccm/client/mod.rs | 1 + 3 files changed, 497 insertions(+), 32 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/client/admission.rs 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..64c6c0aae --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -0,0 +1,378 @@ +//! Crate-private sealing of canonical SCCM client evidence. +//! +//! 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. + +// This is deliberately a crate-private shared interface that lands before +// workflow reducers. No production reducer owns it in this slice, so rustc +// cannot yet observe a call site; retaining the lint allowance here avoids +// weakening workspace-wide warning policy while preserving the review gate. +#![allow(dead_code)] + +use std::collections::{BTreeMap, BTreeSet}; + +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; +use crate::sccm::evidence::SccmRawEvidenceSnapshot; +use crate::sccm::{ + SccmArtifact, SccmCoverageState, SccmEvidence, SccmExtractionProfile, + SccmExtractionProfileMaturity, SccmRole, SccmTimeOrderingState, + SCCM_EXPERIMENTAL_KEY_PROFILE_ID, +}; + +use super::{ + assess_client_intake, SccmClientIntakeAssessment, SccmClientIntakeBundle, + SccmClientIntakeError, SccmClientIntakeFragment, MAX_SCCM_CLIENT_INTAKE_ARTIFACTS, +}; + +/// 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. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SccmClientCapturedPayload { + pub artifact_id: String, + pub bytes: Vec, + pub byte_length: u64, + pub expected_sha256: String, +} + +/// 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. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SccmClientAdmittedEvidence { + evidence: Vec, + source_coverage: BTreeMap, + profiles_by_artifact: BTreeMap, + integrity_seal: String, +} + +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 profile_for_artifact( + &self, + artifact_id: &str, + ) -> Result, SccmClientEvidenceAdmissionError> { + self.verify_integrity()?; + Ok(self.profiles_by_artifact.get(artifact_id)) + } + + 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, + &self.source_coverage, + &self.profiles_by_artifact, + )?; + (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(SccmCoverageState::Captured) => 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_duplicate_first_evidence(&mut self) { + self.evidence.push(self.evidence[0].clone()); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub(crate) 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 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 targets a noncaptured or incomplete fragment")] + NonAdmissibleFragment, + #[error("client evidence admission payload length or digest is invalid")] + PayloadIntegrityMismatch, + #[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 record timestamp provenance is not comparable")] + InvalidTimestampProvenance, + #[error("client evidence admission selected an unregistered extraction profile")] + UnregisteredProfile, + #[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(crate) fn admit_client_evidence( + bundle: &SccmClientIntakeBundle, + assessment: &SccmClientIntakeAssessment, + payloads: &[SccmClientCapturedPayload], +) -> Result { + if payloads.len() > MAX_SCCM_CLIENT_INTAKE_ARTIFACTS { + return Err(SccmClientEvidenceAdmissionError::PayloadLimitExceeded); + } + + 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 eligible = canonical + .physical_artifacts + .iter() + .map(|fragment| { + (fragment.coverage == SccmCoverageState::Captured + && fragment.fragment_complete == Some(true)) + .then_some((fragment.artifact_id.clone(), fragment)) + .ok_or(SccmClientEvidenceAdmissionError::NonAdmissibleFragment) + }) + .collect::, _>>()?; + + if eligible.len() != canonical.physical_artifacts.len() { + return Err(SccmClientEvidenceAdmissionError::NonAdmissibleFragment); + } + if payloads.len() != eligible.len() { + return Err(SccmClientEvidenceAdmissionError::MissingPayload); + } + + 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(); + + for payload in ordered_payloads { + if !seen_payload_ids.insert(payload.artifact_id.as_str()) { + return Err(SccmClientEvidenceAdmissionError::DuplicatePayload); + } + let fragment = eligible + .get(&payload.artifact_id) + .copied() + .ok_or(SccmClientEvidenceAdmissionError::ExtraPayload)?; + validate_payload(payload)?; + + let profile = SccmExtractionProfile::for_version(fragment.configmgr_version.as_deref()); + if !is_registered_client_profile(&profile) { + return Err(SccmClientEvidenceAdmissionError::UnregisteredProfile); + } + let content = decode_payload(payload, fragment.encoding.as_deref())?; + let artifact = artifact_for_fragment(fragment); + let records = scan_logical_records(&content, &fragment.basename); + if records.is_empty() { + return Err(SccmClientEvidenceAdmissionError::MalformedCcm); + } + + for record in 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() + || normalized.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc + || normalized.timestamp.offset_minutes.is_none() + || normalized.timestamp.utc_millis.is_none() + { + 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); + } + evidence.push(normalized); + } + profiles_by_artifact.insert(fragment.artifact_id.clone(), profile); + } + + evidence.sort_by(compare_evidence); + let integrity_seal = + compute_integrity_seal(&evidence, &source_coverage, &profiles_by_artifact)?; + Ok(SccmClientAdmittedEvidence { + evidence, + source_coverage, + profiles_by_artifact, + integrity_seal, + }) +} + +fn validate_payload( + payload: &SccmClientCapturedPayload, +) -> Result<(), SccmClientEvidenceAdmissionError> { + let length = u64::try_from(payload.bytes.len()) + .map_err(|_| SccmClientEvidenceAdmissionError::PayloadIntegrityMismatch)?; + if length != payload.byte_length || !is_lowercase_sha256(&payload.expected_sha256) { + return Err(SccmClientEvidenceAdmissionError::PayloadIntegrityMismatch); + } + (digest_hex(&payload.bytes) == payload.expected_sha256) + .then_some(()) + .ok_or(SccmClientEvidenceAdmissionError::PayloadIntegrityMismatch) +} + +fn decode_payload( + payload: &SccmClientCapturedPayload, + encoding: Option<&str>, +) -> Result { + let encoding = match encoding { + Some("utf-8") => UTF_8, + Some("utf-16le") => UTF_16LE, + Some("utf-16be") => UTF_16BE, + Some("windows-1252") => WINDOWS_1252, + _ => return Err(SccmClientEvidenceAdmissionError::InvalidEncoding), + }; + let (decoded, _, had_errors) = encoding.decode(&payload.bytes); + (!had_errors) + .then_some(decoded.into_owned()) + .ok_or(SccmClientEvidenceAdmissionError::InvalidEncoding) +} + +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 is_registered_client_profile(profile: &SccmExtractionProfile) -> bool { + profile.profile_id == SCCM_EXPERIMENTAL_KEY_PROFILE_ID + && profile.maturity == SccmExtractionProfileMaturity::Experimental + && profile.configmgr_version_prefixes == ["5.00.9128."] + && profile.validated_artifact_families.is_empty() + && profile + .selected_configmgr_version + .as_deref() + .is_some_and(|version| version.starts_with("5.00.9128.")) +} + +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, + profiles_by_artifact: &'a BTreeMap, +} + +fn compute_integrity_seal( + evidence: &[SccmEvidence], + source_coverage: &BTreeMap, + profiles_by_artifact: &BTreeMap, +) -> Result { + let bytes = serde_json::to_vec(&IntegrityProjection { + evidence, + source_coverage, + profiles_by_artifact, + }) + .map_err(|_| SccmClientEvidenceAdmissionError::IntegrityViolation)?; + Ok(digest_hex(&bytes)) +} + +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 index 86f75cff1..5cdf3e4ea 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs @@ -1,9 +1,7 @@ use sha2::{Digest, Sha256}; -use super::{ - admit_client_evidence, assess_client_intake, SccmClientCapturedPayload, - SccmClientIntakeArtifact, SccmClientIntakeBundle, -}; +use super::admission::{admit_client_evidence, SccmClientCapturedPayload}; +use super::{assess_client_intake, SccmClientIntakeArtifact, SccmClientIntakeBundle}; use crate::sccm::{SccmArtifact, SccmCoverageState, SccmRole, SccmRotation}; fn digest(bytes: &[u8]) -> String { @@ -28,7 +26,7 @@ fn artifact(identity: &str, basename: &str) -> SccmClientIntakeArtifact { }; SccmClientIntakeArtifact { artifact: SccmArtifact { - artifact_id: format!("fixture-admitted-{identity}"), + artifact_id: format!("fixture-{identity}"), display_name: basename.to_owned(), original_path: None, host: None, @@ -39,7 +37,7 @@ fn artifact(identity: &str, basename: &str) -> SccmClientIntakeArtifact { coverage: SccmCoverageState::Captured, encoding: Some("utf-8".to_owned()), }, - path_fingerprint: Some(format!("synthetic-admitted-{identity}")), + path_fingerprint: Some(format!("synthetic-{identity}")), rotation_lineage: None, relative_path: Some(format!("evidence/{group}/current/{basename}")), fragment_complete: Some(true), @@ -47,7 +45,7 @@ fn artifact(identity: &str, basename: &str) -> SccmClientIntakeArtifact { } fn payload() -> SccmClientCapturedPayload { - payload_for("fixture-admitted-policy-agent", "+000") + payload_for("fixture-policy-agent", "+000") } fn payload_for(artifact_id: &str, offset: &str) -> SccmClientCapturedPayload { @@ -58,10 +56,10 @@ fn payload_for(artifact_id: &str, offset: &str) -> SccmClientCapturedPayload { .to_owned() + offset + concat!( - "\" date=\"7-30-2026\" ", - "component=\"Synthetic\" context=\"\" type=\"1\" ", - "thread=\"1\" file=\"synthetic.cc:1\">\n" - ); + "\" date=\"7-30-2026\" ", + "component=\"Synthetic\" context=\"\" type=\"1\" ", + "thread=\"1\" file=\"synthetic.cc:1\">\n" + ); let bytes = bytes.into_bytes(); SccmClientCapturedPayload { artifact_id: artifact_id.to_owned(), @@ -71,6 +69,31 @@ fn payload_for(artifact_id: &str, offset: &str) -> SccmClientCapturedPayload { } } +fn numbered_artifact(number: u32) -> 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), + } +} + #[test] fn admission_seals_canonical_records_from_complete_captured_payloads() { let bundle = bundle(); @@ -79,32 +102,47 @@ fn admission_seals_canonical_records_from_complete_captured_payloads() { let admitted = admit_client_evidence(&bundle, &assessment, &[payload()]) .expect("a complete payload with the registered profile is admitted"); - assert_eq!(admitted.evidence().len(), 1); + assert_eq!(admitted.evidence().expect("valid seal").len(), 1); assert!(admitted.verify_integrity().is_ok()); assert_eq!( - admitted.source_coverage("client-policy-agent"), + admitted + .source_coverage("client-policy-agent") + .expect("valid seal"), Some(&SccmCoverageState::Captured) ); + assert!(admitted + .profile_for_artifact("fixture-policy-agent") + .expect("valid seal") + .is_some()); + 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")); + bundle + .artifacts + .push(artifact("policy-state", "CIAgent.log")); let assessment = assess_client_intake(&bundle).expect("two payload fixture is canonical"); let agent = payload(); - let state = payload_for("fixture-admitted-policy-state", "+000"); + let state = payload_for("fixture-policy-state", "+000"); - assert!(admit_client_evidence(&bundle, &assessment, &[agent.clone()]).is_err()); + assert!(admit_client_evidence(&bundle, &assessment, std::slice::from_ref(&agent)).is_err()); let mut extra = vec![agent.clone(), state.clone()]; - extra.push(payload_for("fixture-not-in-bundle", "+000")); + extra.push(payload_for("fixture-unknown", "+000")); assert!(admit_client_evidence(&bundle, &assessment, &extra).is_err()); assert!(admit_client_evidence(&bundle, &assessment, &[agent.clone(), agent]).is_err()); let mut swapped = state; - swapped.artifact_id = "fixture-admitted-policy-agent".to_owned(); + swapped.artifact_id = "fixture-policy-agent".to_owned(); assert!(admit_client_evidence(&bundle, &assessment, &[payload(), swapped]).is_err()); } @@ -122,6 +160,28 @@ fn admission_rejects_payload_digest_and_length_mismatches() { assert!(admit_client_evidence(&bundle, &assessment, &[bad_length]).is_err()); } +#[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::>(); + assert!(admit_client_evidence(&bundle, &assessment, &payloads).is_ok()); + + let mut overflow = payloads; + overflow.push(payload_for("fixture-overflow", "+000")); + assert!(admit_client_evidence(&bundle, &assessment, &overflow).is_err()); +} + #[test] fn admission_rejects_noncaptured_incomplete_malformed_and_invalid_offset_payloads() { let mut capped = bundle(); @@ -136,8 +196,17 @@ fn admission_rejects_noncaptured_incomplete_malformed_and_invalid_offset_payload 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"); + assert!( + admit_client_evidence(&unknown_profile, &unknown_profile_assessment, &[payload()],) + .is_err() + ); + let malformed = SccmClientCapturedPayload { - artifact_id: "fixture-admitted-policy-agent".to_owned(), + artifact_id: "fixture-policy-agent".to_owned(), bytes: b"not a CCM logical record".to_vec(), byte_length: 24, expected_sha256: digest(b"not a CCM logical record"), @@ -145,39 +214,52 @@ fn admission_rejects_noncaptured_incomplete_malformed_and_invalid_offset_payload let assessment = assess_client_intake(&bundle()).expect("fixture assessment is canonical"); assert!(admit_client_evidence(&bundle(), &assessment, &[malformed]).is_err()); - assert!(admit_client_evidence(&bundle(), &assessment, &[payload_for( - "fixture-admitted-policy-agent", - "+9999", - )]) + assert!(admit_client_evidence( + &bundle(), + &assessment, + &[payload_for("fixture-policy-agent", "+9999",)] + ) .is_err()); } #[test] fn admission_reassesses_bundle_and_is_deterministic_across_payload_order() { let mut bundle = bundle(); - bundle.artifacts.push(artifact("policy-state", "CIAgent.log")); + bundle + .artifacts + .push(artifact("policy-state", "CIAgent.log")); let canonical = assess_client_intake(&bundle).expect("canonical assessment"); let mut forged = canonical.clone(); - forged.groups[0].fragments.clear(); - assert!(admit_client_evidence(&bundle, &forged, &[payload(), payload_for( - "fixture-admitted-policy-state", - "+000", - )]) + 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-admitted-policy-state", "+000")], + &[payload(), payload_for("fixture-policy-state", "+000")], ) .expect("forward payload ordering is admitted"); let reverse = admit_client_evidence( &bundle, &canonical, - &[payload_for("fixture-admitted-policy-state", "+000"), payload()], + &[payload_for("fixture-policy-state", "+000"), payload()], ) .expect("reverse payload ordering is admitted"); - assert_eq!(forward.evidence(), reverse.evidence()); + assert_eq!( + forward.evidence().expect("forward seal"), + reverse.evidence().expect("reverse seal") + ); assert_eq!(forward.integrity_seal(), reverse.integrity_seal()); } @@ -190,11 +272,15 @@ fn admission_integrity_rejects_test_only_record_profile_and_identity_collisions( 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 + .profile_for_artifact("fixture-policy-agent") + .is_err()); let mut collision = admit_client_evidence(&bundle, &assessment, &[payload()]).expect("admitted evidence"); diff --git a/crates/cmtraceopen-parser/src/sccm/client/mod.rs b/crates/cmtraceopen-parser/src/sccm/client/mod.rs index 5af403fb4..8af5acd94 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -3,6 +3,7 @@ //! This module accepts already-supplied metadata. Native discovery and capture //! remain outside `cmtraceopen-parser`. +pub(crate) mod admission; mod intake; #[cfg(test)] From 345422eafd6fe82aa0e0b997b5df9f7a315fa09b Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 11:45:44 -0400 Subject: [PATCH 327/422] test(sccm): expose client admission framing and cap gaps --- .../src/sccm/client/admission_tests.rs | 124 ++++++++++++++++-- 1 file changed, 113 insertions(+), 11 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs index 5cdf3e4ea..ab10158a3 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs @@ -49,18 +49,48 @@ fn payload() -> SccmClientCapturedPayload { } fn payload_for(artifact_id: &str, offset: &str) -> SccmClientCapturedPayload { - let bytes = concat!( - "", - " String { + format!( + concat!( + "\n" + ), + message = message, + offset = offset, ) - .to_owned() - + offset - + concat!( - "\" date=\"7-30-2026\" ", - "component=\"Synthetic\" context=\"\" type=\"1\" ", - "thread=\"1\" file=\"synthetic.cc:1\">\n" - ); - let bytes = bytes.into_bytes(); +} + +fn refresh_payload_integrity(payload: &mut SccmClientCapturedPayload) { + payload.byte_length = payload.bytes.len() as u64; + payload.expected_sha256 = digest(&payload.bytes); +} + +fn payload_padded_to(artifact_id: &str, total_bytes: usize) -> SccmClientCapturedPayload { + let mut payload = payload_for(artifact_id, "+000"); + assert!(payload.bytes.len() <= total_bytes, "test payload remains valid"); + payload.bytes.resize(total_bytes, b' '); + refresh_payload_integrity(&mut payload); + payload +} + +fn payload_with_repeated_records( + artifact_id: &str, + record_count: usize, + message: &str, +) -> SccmClientCapturedPayload { + let bytes = ccm_record(message, "+000") + .repeat(record_count) + .into_bytes(); SccmClientCapturedPayload { artifact_id: artifact_id.to_owned(), byte_length: bytes.len() as u64, @@ -222,6 +252,78 @@ fn admission_rejects_noncaptured_incomplete_malformed_and_invalid_offset_payload .is_err()); } +#[test] +fn admission_rejects_unclosed_ccm_suffix_even_when_manifest_claims_complete() { + let bundle = bundle(); + let assessment = assess_client_intake(&bundle).expect("fixture assessment is canonical"); + let mut truncated = payload(); + truncated + .bytes + .extend_from_slice(b">(); + let error = admit_client_evidence(&aggregate_bundle, &aggregate_assessment, &aggregate_payloads) + .expect_err("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_records = + payload_with_repeated_records("fixture-policy-agent", 4_097, "synthetic record"); + let error = admit_client_evidence(&bundle, &assessment, &[too_many_records]) + .expect_err("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 = payload_with_repeated_records( + "fixture-policy-agent", + 1_024, + &retained_message, + ); + let error = admit_client_evidence(&bundle, &assessment, &[oversized_retained]) + .expect_err("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_reassesses_bundle_and_is_deterministic_across_payload_order() { let mut bundle = bundle(); From 4c660f8d8bbd387e9fbbfa2a1c82b9c2141f16d1 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 11:55:53 -0400 Subject: [PATCH 328/422] fix(sccm): bound client evidence admission --- crates/cmtraceopen-parser/src/parser/ccm.rs | 146 ++++++++++---- .../src/sccm/client/admission.rs | 180 ++++++++++++++++-- .../src/sccm/client/admission_tests.rs | 20 +- 3 files changed, 293 insertions(+), 53 deletions(-) diff --git a/crates/cmtraceopen-parser/src/parser/ccm.rs b/crates/cmtraceopen-parser/src/parser/ccm.rs index ee9429b63..a2abbc176 100644 --- a/crates/cmtraceopen-parser/src/parser/ccm.rs +++ b/crates/cmtraceopen-parser/src/parser/ccm.rs @@ -344,7 +344,7 @@ 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); + let scan = scan_ccm_content(content, file_path, CcmScanMode::PublicProjection, None); ( scan.records .into_iter() @@ -355,13 +355,44 @@ fn parse_content_multiline(content: &str, file_path: &str) -> (Vec, u3 } pub(crate) fn scan_logical_records(content: &str, file_path: &str) -> Vec { - scan_ccm_content(content, file_path, CcmScanMode::SccmEvidence) + 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, +} + +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), + ); + 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, + } +} + #[derive(Clone, Copy, PartialEq, Eq)] enum CcmScanMode { PublicProjection, @@ -371,13 +402,57 @@ enum CcmScanMode { 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, } -fn scan_ccm_content(content: &str, file_path: &str, mode: CcmScanMode) -> CcmScan { +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 records = 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; @@ -404,9 +479,7 @@ fn scan_ccm_content(content: &str, file_path: &str, mode: CcmScanMode) -> CcmSca cursor, &line_starts, file_path, - &mut records, - &mut id_counter, - &mut errors, + &mut build, ); cursor = full_match.end(); search_cursor = full_match.end(); @@ -420,27 +493,29 @@ fn scan_ccm_content(content: &str, file_path: &str, mode: CcmScanMode) -> CcmSca cursor, &line_starts, file_path, - &mut records, - &mut id_counter, - &mut errors, + &mut build, ); 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) { if parsed.public_compatible || mode == CcmScanMode::SccmEvidence { - records - .push(parsed.into_logical_record(id_counter, line_start, line_end, file_path)); - id_counter += 1; + if !build.record_limit_reached() { + 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 records, - &mut id_counter, - &mut errors, + &mut build, ); } } else { @@ -449,9 +524,7 @@ fn scan_ccm_content(content: &str, file_path: &str, mode: CcmScanMode) -> CcmSca full_match.start(), &line_starts, file_path, - &mut records, - &mut id_counter, - &mut errors, + &mut build, ); } @@ -466,11 +539,13 @@ fn scan_ccm_content(content: &str, file_path: &str, mode: CcmScanMode) -> CcmSca cursor, &line_starts, file_path, - &mut records, - &mut id_counter, - &mut errors, + &mut build, ); + if !matched_any && record_limit.is_some() { + return build.finish(); + } + if !matched_any { let lines: Vec<&str> = content.lines().collect(); let (entries, errors) = parse_lines(&lines, file_path); @@ -487,10 +562,14 @@ fn scan_ccm_content(content: &str, file_path: &str, mode: CcmScanMode) -> CcmSca } }) .collect(); - return CcmScan { records, errors }; + return CcmScan { + records, + errors, + record_limit_exceeded: build.record_limit_exceeded, + }; } - CcmScan { records, errors } + build.finish() } fn newest_nested_opener( @@ -636,18 +715,19 @@ fn push_unmatched_plain( base_offset: usize, line_starts: &[usize], file_path: &str, - records: &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() { + if build.record_limit_reached() { + return; + } let line_number = line_number_for_offset(line_starts, base_offset + local_offset); let entry = LogEntry { - id: *id_counter, + id: build.id_counter, line_number, message: trimmed.to_string(), component: None, @@ -696,15 +776,15 @@ fn push_unmatched_plain( iteration: None, tags: None, }; - records.push(CcmLogicalRecord { + 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(); } diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index 64c6c0aae..f5ce8fb3c 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -10,14 +10,17 @@ // weakening workspace-wide warning policy while preserving the review gate. #![allow(dead_code)] -use std::collections::{BTreeMap, BTreeSet}; +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; +use crate::parser::ccm::scan_logical_records_bounded; use crate::sccm::evidence::SccmRawEvidenceSnapshot; use crate::sccm::{ SccmArtifact, SccmCoverageState, SccmEvidence, SccmExtractionProfile, @@ -30,6 +33,19 @@ use super::{ SccmClientIntakeError, SccmClientIntakeFragment, MAX_SCCM_CLIENT_INTAKE_ARTIFACTS, }; +/// 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 into a deterministic client evidence integrity seal. +pub(crate) const MAX_SCCM_CLIENT_ADMISSION_SEAL_BYTES: usize = 4 * 1024 * 1024; + /// 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. @@ -133,6 +149,10 @@ pub(crate) enum SccmClientEvidenceAdmissionError { 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 contains duplicate payload artifact IDs")] DuplicatePayload, #[error("client evidence admission is missing a payload for a captured fragment")] @@ -147,6 +167,14 @@ pub(crate) enum SccmClientEvidenceAdmissionError { 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 selected an unregistered extraction profile")] @@ -171,6 +199,7 @@ pub(crate) fn admit_client_evidence( 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)?; @@ -208,6 +237,7 @@ pub(crate) fn admit_client_evidence( 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; for payload in ordered_payloads { if !seen_payload_ids.insert(payload.artifact_id.as_str()) { @@ -225,12 +255,22 @@ pub(crate) fn admit_client_evidence( } let content = decode_payload(payload, fragment.encoding.as_deref())?; let artifact = artifact_for_fragment(fragment); - let records = scan_logical_records(&content, &fragment.basename); - if records.is_empty() { + let scan = scan_logical_records_bounded( + &content, + &fragment.basename, + MAX_SCCM_CLIENT_ADMISSION_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); } - for record in records { + 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() @@ -252,6 +292,12 @@ pub(crate) fn admit_client_evidence( { 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); @@ -268,6 +314,24 @@ pub(crate) fn admit_client_evidence( }) } +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 validate_payload( payload: &SccmClientCapturedPayload, ) -> Result<(), SccmClientEvidenceAdmissionError> { @@ -281,6 +345,42 @@ fn validate_payload( .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>, @@ -349,18 +449,74 @@ struct IntegrityProjection<'a> { profiles_by_artifact: &'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(()) + } +} + fn compute_integrity_seal( evidence: &[SccmEvidence], source_coverage: &BTreeMap, profiles_by_artifact: &BTreeMap, ) -> Result { - let bytes = serde_json::to_vec(&IntegrityProjection { - evidence, - source_coverage, - profiles_by_artifact, - }) - .map_err(|_| SccmClientEvidenceAdmissionError::IntegrityViolation)?; - Ok(digest_hex(&bytes)) + let mut writer = BoundedIntegrityWriter::new(MAX_SCCM_CLIENT_ADMISSION_SEAL_BYTES); + let serialized = serde_json::to_writer( + &mut writer, + &IntegrityProjection { + evidence, + source_coverage, + profiles_by_artifact, + }, + ); + if serialized.is_err() { + return Err(if writer.limit_exceeded { + SccmClientEvidenceAdmissionError::IntegritySealLimitExceeded + } else { + SccmClientEvidenceAdmissionError::IntegrityViolation + }); + } + Ok(writer.finish()) } fn digest_hex(bytes: &[u8]) -> String { diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs index ab10158a3..be2c5beec 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs @@ -77,7 +77,10 @@ fn refresh_payload_integrity(payload: &mut SccmClientCapturedPayload) { fn payload_padded_to(artifact_id: &str, total_bytes: usize) -> SccmClientCapturedPayload { let mut payload = payload_for(artifact_id, "+000"); - assert!(payload.bytes.len() <= total_bytes, "test payload remains valid"); + assert!( + payload.bytes.len() <= total_bytes, + "test payload remains valid" + ); payload.bytes.resize(total_bytes, b' '); refresh_payload_integrity(&mut payload); payload @@ -294,8 +297,12 @@ fn admission_rejects_oversized_payload_work_records_and_retained_evidence() { .iter() .map(|artifact| payload_padded_to(&artifact.artifact.artifact_id, 4 * 1024 * 1024)) .collect::>(); - let error = admit_client_evidence(&aggregate_bundle, &aggregate_assessment, &aggregate_payloads) - .expect_err("aggregate parser work must be capped before hashing every payload"); + let error = admit_client_evidence( + &aggregate_bundle, + &aggregate_assessment, + &aggregate_payloads, + ) + .expect_err("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" @@ -311,11 +318,8 @@ fn admission_rejects_oversized_payload_work_records_and_retained_evidence() { ); let retained_message = "x".repeat(3_000); - let oversized_retained = payload_with_repeated_records( - "fixture-policy-agent", - 1_024, - &retained_message, - ); + let oversized_retained = + payload_with_repeated_records("fixture-policy-agent", 1_024, &retained_message); let error = admit_client_evidence(&bundle, &assessment, &[oversized_retained]) .expect_err("retained evidence and seal input must remain bounded"); assert_eq!( From 30f85332ddd715360500a19b192467ea382455a4 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 12:05:46 -0400 Subject: [PATCH 329/422] test(sccm): expose aggregate client record cap bypass --- .../src/sccm/client/admission_tests.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs index be2c5beec..77927038b 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs @@ -328,6 +328,32 @@ fn admission_rejects_oversized_payload_work_records_and_retained_evidence() { ); } +#[test] +fn admission_enforces_logical_record_cap_across_all_payloads() { + let bundle = SccmClientIntakeBundle { + artifacts: (1..=2).map(numbered_artifact).collect(), + capture_gaps: Vec::new(), + }; + let assessment = + assess_client_intake(&bundle).expect("two captured rotations are canonical intake"); + let payloads = bundle + .artifacts + .iter() + .map(|artifact| payload_with_repeated_records(&artifact.artifact.artifact_id, 2_049, "x")) + .collect::>(); + + let result = admit_client_evidence(&bundle, &assessment, &payloads); + assert!( + result.is_err(), + "two individually bounded rotations totaling 4,098 records were admitted" + ); + let error = result.expect_err("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" + ); +} + #[test] fn admission_reassesses_bundle_and_is_deterministic_across_payload_order() { let mut bundle = bundle(); From d7bb62987960fed3625d94cf2274a4959f0ae19e Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 12:06:26 -0400 Subject: [PATCH 330/422] fix(sccm): enforce aggregate client record cap --- crates/cmtraceopen-parser/src/sccm/client/admission.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index f5ce8fb3c..ca9d8eaa2 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -238,6 +238,7 @@ pub(crate) fn admit_client_evidence( 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()) { @@ -269,6 +270,9 @@ pub(crate) fn admit_client_evidence( 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(); From dc07cf9759b2a065282bd75f28506f03107a5c05 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 12:12:59 -0400 Subject: [PATCH 331/422] test(sccm): expose client evidence authority gaps --- .../sccm/client/authority_contract_tests.rs | 402 ++++++++++++++++++ .../cmtraceopen-parser/src/sccm/client/mod.rs | 2 + .../tests/sccm_client_admission_authority.rs | 75 ++++ 3 files changed, 479 insertions(+) create mode 100644 crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_admission_authority.rs 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..8718d93d9 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs @@ -0,0 +1,402 @@ +use sha2::{Digest, Sha256}; + +use super::admission::{ + admit_client_evidence, SccmClientAdmittedEvidence, SccmClientCapturedPayload, + SccmClientEvidenceAdmissionError, +}; +use super::{assess_client_intake, SccmClientIntakeArtifact, SccmClientIntakeBundle}; +use crate::sccm::{SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmRole, SccmRotation}; + +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", + "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 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( + "legacy", + "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-legacy", 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-extra", 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, + ), + ]); + 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_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 profile = admitted + .profile_for_artifact("fixture-policy") + .expect("valid authority seal") + .expect("admitted artifact has a profile"); + assert_eq!( + profile.validated_artifact_families, + [SccmArtifactFamily::ClientPolicy] + ); +} + +#[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/mod.rs b/crates/cmtraceopen-parser/src/sccm/client/mod.rs index 8af5acd94..b2df4278e 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -8,5 +8,7 @@ mod intake; #[cfg(test)] mod admission_tests; +#[cfg(test)] +mod authority_contract_tests; pub use intake::*; 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..124ac0277 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_admission_authority.rs @@ -0,0 +1,75 @@ +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 bundle = SccmClientIntakeBundle { + artifacts: vec![SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: "fixture-public-authority".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-public-authority".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(bytes.len() as u64), + content_sha256: Some(digest(&bytes)), + }], + capture_gaps: Vec::new(), + }; + let assessment = assess_client_intake(&bundle).expect("public intake is canonical"); + let payload = match SccmClientCapturedPayload::new("fixture-public-authority", bytes) { + Ok(payload) => payload, + Err(error) => panic!("public payload constructor rejected canonical bytes: {error}"), + }; + + assert!( + admit_client_evidence(&bundle, &assessment, &[payload]).is_ok(), + "public callers can obtain opaque authority only for intake-bound bytes" + ); + + 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(round_trip.artifacts[0].declared_byte_length.unwrap()) + ); +} From 32ee8f6f37c3dbef86f1ffa95e903092775d9bed Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 12:25:30 -0400 Subject: [PATCH 332/422] fix(sccm): bind client evidence to intake authority --- .../src/sccm/client/admission.rs | 179 +++++++--- .../src/sccm/client/admission_tests.rs | 309 ++++++++++++------ .../sccm/client/authority_contract_tests.rs | 41 ++- .../src/sccm/client/intake.rs | 56 +++- .../cmtraceopen-parser/src/sccm/client/mod.rs | 4 + .../tests/sccm_client_admission_authority.rs | 6 +- .../tests/sccm_client_intake.rs | 8 + .../preparation/issue-319-client-intake.md | 21 +- 8 files changed, 462 insertions(+), 162 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index ca9d8eaa2..5e96678e8 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -1,13 +1,13 @@ -//! Crate-private sealing of canonical SCCM client evidence. +//! 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. -// This is deliberately a crate-private shared interface that lands before -// workflow reducers. No production reducer owns it in this slice, so rustc -// cannot yet observe a call site; retaining the lint allowance here avoids -// weakening workspace-wide warning policy while preserving the review gate. +// 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::{ @@ -21,16 +21,18 @@ 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::{ - SccmArtifact, SccmCoverageState, SccmEvidence, SccmExtractionProfile, + SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmEvidence, SccmExtractionProfile, SccmExtractionProfileMaturity, SccmRole, SccmTimeOrderingState, SCCM_EXPERIMENTAL_KEY_PROFILE_ID, }; use super::{ - assess_client_intake, SccmClientIntakeAssessment, SccmClientIntakeBundle, - SccmClientIntakeError, SccmClientIntakeFragment, MAX_SCCM_CLIENT_INTAKE_ARTIFACTS, + assess_client_intake, intake::is_safe_artifact_id, SccmClientIntakeAssessment, + SccmClientIntakeBundle, SccmClientIntakeError, SccmClientIntakeFragment, + MAX_SCCM_CLIENT_INTAKE_ARTIFACTS, }; /// Maximum raw bytes decoded from one client payload at the parser admission @@ -49,12 +51,28 @@ pub(crate) const MAX_SCCM_CLIENT_ADMISSION_SEAL_BYTES: usize = 4 * 1024 * 1024; /// 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. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct SccmClientCapturedPayload { - pub artifact_id: String, - pub bytes: Vec, - pub byte_length: u64, - pub expected_sha256: String, +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. @@ -62,10 +80,10 @@ pub(crate) struct SccmClientCapturedPayload { /// 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. -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct SccmClientAdmittedEvidence { +pub struct SccmClientAdmittedEvidence { evidence: Vec, source_coverage: BTreeMap, + admitted_source_groups: BTreeSet, profiles_by_artifact: BTreeMap, integrity_seal: String, } @@ -100,6 +118,7 @@ impl SccmClientAdmittedEvidence { let recomputed = compute_integrity_seal( &self.evidence, &self.source_coverage, + &self.admitted_source_groups, &self.profiles_by_artifact, )?; (recomputed == self.integrity_seal) @@ -114,7 +133,11 @@ impl SccmClientAdmittedEvidence { logical_artifact_id: &str, ) -> Result<(), SccmClientEvidenceAdmissionError> { match self.source_coverage(logical_artifact_id)? { - Some(SccmCoverageState::Captured) => Ok(()), + Some(SccmCoverageState::Captured) + if self.admitted_source_groups.contains(logical_artifact_id) => + { + Ok(()) + } Some(_) => Err(SccmClientEvidenceAdmissionError::SourceCoverageUnavailable), None => Err(SccmClientEvidenceAdmissionError::UnknownSourceGroup), } @@ -142,7 +165,7 @@ impl SccmClientAdmittedEvidence { } #[derive(Debug, Clone, PartialEq, Eq, Error)] -pub(crate) enum SccmClientEvidenceAdmissionError { +pub enum SccmClientEvidenceAdmissionError { #[error("client evidence admission bundle is invalid: {0}")] InvalidBundle(SccmClientIntakeError), #[error("client evidence admission assessment is not the canonical bundle projection")] @@ -153,16 +176,18 @@ pub(crate) enum SccmClientEvidenceAdmissionError { 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 targets a noncaptured or incomplete fragment")] - NonAdmissibleFragment, #[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")] @@ -191,7 +216,7 @@ pub(crate) enum SccmClientEvidenceAdmissionError { /// Reassesses a canonical client bundle and seals the logical CCM evidence it /// derives from each exact complete captured payload. -pub(crate) fn admit_client_evidence( +pub fn admit_client_evidence( bundle: &SccmClientIntakeBundle, assessment: &SccmClientIntakeAssessment, payloads: &[SccmClientCapturedPayload], @@ -212,19 +237,40 @@ pub(crate) fn admit_client_evidence( .iter() .map(|group| (group.logical_artifact_id.clone(), group.coverage.clone())) .collect::>(); - let eligible = canonical - .physical_artifacts + let mut eligible = BTreeMap::new(); + let mut unbound_complete_captures = BTreeSet::new(); + for fragment in &canonical.physical_artifacts { + if fragment.coverage != SccmCoverageState::Captured + || fragment.fragment_complete != Some(true) + { + continue; + } + if fragment.declared_byte_length.is_none() || fragment.content_sha256.is_none() { + unbound_complete_captures.insert(fragment.artifact_id.as_str()); + continue; + } + eligible.insert(fragment.artifact_id.clone(), fragment); + } + let admitted_source_groups = canonical + .groups .iter() - .map(|fragment| { - (fragment.coverage == SccmCoverageState::Captured - && fragment.fragment_complete == Some(true)) - .then_some((fragment.artifact_id.clone(), fragment)) - .ok_or(SccmClientEvidenceAdmissionError::NonAdmissibleFragment) + .filter(|group| { + !group.fragments.is_empty() + && group.coverage == SccmCoverageState::Captured + && group.fragments.iter().all(|fragment| { + fragment.coverage == SccmCoverageState::Captured + && fragment.fragment_complete == Some(true) + && fragment.declared_byte_length.is_some() + && fragment.content_sha256.is_some() + }) }) - .collect::, _>>()?; - - if eligible.len() != canonical.physical_artifacts.len() { - return Err(SccmClientEvidenceAdmissionError::NonAdmissibleFragment); + .map(|group| group.logical_artifact_id.clone()) + .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); @@ -248,10 +294,13 @@ pub(crate) fn admit_client_evidence( .get(&payload.artifact_id) .copied() .ok_or(SccmClientEvidenceAdmissionError::ExtraPayload)?; - validate_payload(payload)?; + validate_payload(payload, fragment)?; - let profile = SccmExtractionProfile::for_version(fragment.configmgr_version.as_deref()); - if !is_registered_client_profile(&profile) { + let classified = classify_artifact_name(&fragment.basename, SccmRole::Client); + let family = classified.family; + let mut profile = SccmExtractionProfile::for_version(fragment.configmgr_version.as_deref()); + profile.validated_artifact_families = vec![family.clone()]; + if !classified.supported_for_diagnosis || !is_registered_client_profile(&profile, &family) { return Err(SccmClientEvidenceAdmissionError::UnregisteredProfile); } let content = decode_payload(payload, fragment.encoding.as_deref())?; @@ -308,11 +357,16 @@ pub(crate) fn admit_client_evidence( } evidence.sort_by(compare_evidence); - let integrity_seal = - compute_integrity_seal(&evidence, &source_coverage, &profiles_by_artifact)?; + let integrity_seal = compute_integrity_seal( + &evidence, + &source_coverage, + &admitted_source_groups, + &profiles_by_artifact, + )?; Ok(SccmClientAdmittedEvidence { evidence, source_coverage, + admitted_source_groups, profiles_by_artifact, integrity_seal, }) @@ -338,13 +392,21 @@ fn validate_payload_budget( fn validate_payload( payload: &SccmClientCapturedPayload, + fragment: &SccmClientIntakeFragment, ) -> Result<(), SccmClientEvidenceAdmissionError> { let length = u64::try_from(payload.bytes.len()) .map_err(|_| SccmClientEvidenceAdmissionError::PayloadIntegrityMismatch)?; - if length != payload.byte_length || !is_lowercase_sha256(&payload.expected_sha256) { + 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) == payload.expected_sha256) + (digest_hex(&payload.bytes) == declared_digest) .then_some(()) .ok_or(SccmClientEvidenceAdmissionError::PayloadIntegrityMismatch) } @@ -417,11 +479,15 @@ fn artifact_for_fragment(fragment: &SccmClientIntakeFragment) -> SccmArtifact { } } -fn is_registered_client_profile(profile: &SccmExtractionProfile) -> bool { +fn is_registered_client_profile( + profile: &SccmExtractionProfile, + family: &SccmArtifactFamily, +) -> bool { profile.profile_id == SCCM_EXPERIMENTAL_KEY_PROFILE_ID && profile.maturity == SccmExtractionProfileMaturity::Experimental && profile.configmgr_version_prefixes == ["5.00.9128."] - && profile.validated_artifact_families.is_empty() + && profile.validated_artifact_families.first() == Some(family) + && profile.validated_artifact_families.len() == 1 && profile .selected_configmgr_version .as_deref() @@ -450,7 +516,9 @@ fn compare_evidence(left: &SccmEvidence, right: &SccmEvidence) -> std::cmp::Orde struct IntegrityProjection<'a> { evidence: &'a [SccmEvidence], source_coverage: &'a BTreeMap, - profiles_by_artifact: &'a BTreeMap, + admitted_source_groups: &'a BTreeSet, + profile_assignments: &'a BTreeMap<&'a str, usize>, + profiles: &'a [&'a SccmExtractionProfile], } struct BoundedIntegrityWriter { @@ -502,15 +570,40 @@ impl Write for BoundedIntegrityWriter { fn compute_integrity_seal( evidence: &[SccmEvidence], source_coverage: &BTreeMap, + admitted_source_groups: &BTreeSet, profiles_by_artifact: &BTreeMap, ) -> Result { + // 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 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); + } + let mut writer = BoundedIntegrityWriter::new(MAX_SCCM_CLIENT_ADMISSION_SEAL_BYTES); let serialized = serde_json::to_writer( &mut writer, &IntegrityProjection { evidence, source_coverage, - profiles_by_artifact, + admitted_source_groups, + profile_assignments: &profile_assignments, + profiles: &unique_profiles, }, ); if serialized.is_err() { diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs index 77927038b..a42269835 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs @@ -1,6 +1,9 @@ use sha2::{Digest, Sha256}; -use super::admission::{admit_client_evidence, SccmClientCapturedPayload}; +use super::admission::{ + admit_client_evidence, SccmClientAdmittedEvidence, SccmClientCapturedPayload, + SccmClientEvidenceAdmissionError, +}; use super::{assess_client_intake, SccmClientIntakeArtifact, SccmClientIntakeBundle}; use crate::sccm::{SccmArtifact, SccmCoverageState, SccmRole, SccmRotation}; @@ -18,7 +21,19 @@ fn bundle() -> SccmClientIntakeBundle { } } +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", @@ -41,6 +56,8 @@ fn artifact(identity: &str, basename: &str) -> SccmClientIntakeArtifact { 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)), } } @@ -49,12 +66,17 @@ fn payload() -> SccmClientCapturedPayload { } fn payload_for(artifact_id: &str, offset: &str) -> SccmClientCapturedPayload { - let bytes = ccm_record("SYNTHETIC FIXTURE admitted policy", offset).into_bytes(); - SccmClientCapturedPayload { - artifact_id: artifact_id.to_owned(), - byte_length: bytes.len() as u64, - expected_sha256: digest(&bytes), - bytes, + 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}"), } } @@ -70,39 +92,31 @@ fn ccm_record(message: &str, offset: &str) -> String { ) } -fn refresh_payload_integrity(payload: &mut SccmClientCapturedPayload) { - payload.byte_length = payload.bytes.len() as u64; - payload.expected_sha256 = digest(&payload.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_padded_to(artifact_id: &str, total_bytes: usize) -> SccmClientCapturedPayload { - let mut payload = payload_for(artifact_id, "+000"); - assert!( - payload.bytes.len() <= total_bytes, - "test payload remains valid" - ); - payload.bytes.resize(total_bytes, b' '); - refresh_payload_integrity(&mut payload); - payload +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 payload_with_repeated_records( - artifact_id: &str, - record_count: usize, - message: &str, -) -> SccmClientCapturedPayload { - let bytes = ccm_record(message, "+000") +fn repeated_record_bytes(record_count: usize, message: &str) -> Vec { + ccm_record(message, "+000") .repeat(record_count) - .into_bytes(); - SccmClientCapturedPayload { - artifact_id: artifact_id.to_owned(), - byte_length: bytes.len() as u64, - expected_sha256: digest(&bytes), - bytes, - } + .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 { @@ -124,6 +138,18 @@ fn numbered_artifact(number: u32) -> SccmClientIntakeArtifact { "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, } } @@ -163,34 +189,54 @@ fn admission_rejects_missing_extra_duplicate_and_swapped_payloads() { .artifacts .push(artifact("policy-state", "CIAgent.log")); let assessment = assess_client_intake(&bundle).expect("two payload fixture is canonical"); - let agent = payload(); - let state = payload_for("fixture-policy-state", "+000"); - - assert!(admit_client_evidence(&bundle, &assessment, std::slice::from_ref(&agent)).is_err()); + assert!(admit_client_evidence(&bundle, &assessment, &[payload()]).is_err()); - let mut extra = vec![agent.clone(), state.clone()]; - extra.push(payload_for("fixture-unknown", "+000")); - assert!(admit_client_evidence(&bundle, &assessment, &extra).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, &[agent.clone(), agent]).is_err()); + assert!(admit_client_evidence(&bundle, &assessment, &[payload(), payload()]).is_err()); - let mut swapped = state; - swapped.artifact_id = "fixture-policy-agent".to_owned(); - assert!(admit_client_evidence(&bundle, &assessment, &[payload(), swapped]).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_rejects_payload_digest_and_length_mismatches() { - let bundle = bundle(); - let assessment = assess_client_intake(&bundle).expect("fixture assessment is canonical"); - - let mut bad_digest = payload(); - bad_digest.expected_sha256 = "0".repeat(64); - assert!(admit_client_evidence(&bundle, &assessment, &[bad_digest]).is_err()); + 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 = payload(); - bad_length.byte_length += 1; - assert!(admit_client_evidence(&bundle, &assessment, &[bad_length]).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] @@ -208,10 +254,12 @@ fn admission_accepts_the_exact_cap_and_rejects_payload_overflow_before_reassessm .iter() .map(|artifact| payload_for(&artifact.artifact.artifact_id, "+000")) .collect::>(); - assert!(admit_client_evidence(&bundle, &assessment, &payloads).is_ok()); + 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-overflow", "+000")); + overflow.push(payload_for("fixture-policy-approved", "+000")); assert!(admit_client_evidence(&bundle, &assessment, &overflow).is_err()); } @@ -220,11 +268,15 @@ fn admission_rejects_noncaptured_incomplete_malformed_and_invalid_offset_payload 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()); @@ -238,35 +290,46 @@ fn admission_rejects_noncaptured_incomplete_malformed_and_invalid_offset_payload .is_err() ); - let malformed = SccmClientCapturedPayload { - artifact_id: "fixture-policy-agent".to_owned(), - bytes: b"not a CCM logical record".to_vec(), - byte_length: 24, - expected_sha256: digest(b"not a CCM logical record"), - }; - let assessment = assess_client_intake(&bundle()).expect("fixture assessment is canonical"); - assert!(admit_client_evidence(&bundle(), &assessment, &[malformed]).is_err()); + 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"); assert!(admit_client_evidence( - &bundle(), - &assessment, - &[payload_for("fixture-policy-agent", "+9999",)] + &invalid_offset_bundle, + &invalid_offset_assessment, + &[payload_from_bytes( + "fixture-policy-agent", + invalid_offset_bytes, + )] ) .is_err()); } #[test] fn admission_rejects_unclosed_ccm_suffix_even_when_manifest_claims_complete() { - let bundle = bundle(); + 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: (1..=5).map(numbered_artifact).collect(), + artifacts: aggregate_artifacts, capture_gaps: Vec::new(), }; let aggregate_assessment = assess_client_intake(&aggregate_bundle).expect("aggregate fixture assessment is canonical"); - let aggregate_payloads = aggregate_bundle - .artifacts - .iter() - .map(|artifact| payload_padded_to(&artifact.artifact.artifact_id, 4 * 1024 * 1024)) - .collect::>(); - let error = admit_client_evidence( - &aggregate_bundle, - &aggregate_assessment, - &aggregate_payloads, - ) - .expect_err("aggregate parser work must be capped before hashing every payload"); + 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_records = - payload_with_repeated_records("fixture-policy-agent", 4_097, "synthetic record"); - let error = admit_client_evidence(&bundle, &assessment, &[too_many_records]) - .expect_err("logical-record expansion must be capped before evidence retention"); + 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 = - payload_with_repeated_records("fixture-policy-agent", 1_024, &retained_message); - let error = admit_client_evidence(&bundle, &assessment, &[oversized_retained]) - .expect_err("retained evidence and seal input must remain bounded"); + 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" @@ -330,24 +419,30 @@ fn admission_rejects_oversized_payload_work_records_and_retained_evidence() { #[test] fn admission_enforces_logical_record_cap_across_all_payloads() { + let mut artifacts = Vec::new(); + let mut payloads = Vec::new(); + for number in 1..=2 { + let artifact_id = format!("sccm-artifact:v1:sha256:{number:064x}"); + let bytes = repeated_record_bytes(2_049, "x"); + artifacts.push(numbered_artifact_bound_to(number, &bytes)); + payloads.push(payload_from_bytes(&artifact_id, bytes)); + } let bundle = SccmClientIntakeBundle { - artifacts: (1..=2).map(numbered_artifact).collect(), + artifacts, capture_gaps: Vec::new(), }; let assessment = assess_client_intake(&bundle).expect("two captured rotations are canonical intake"); - let payloads = bundle - .artifacts - .iter() - .map(|artifact| payload_with_repeated_records(&artifact.artifact.artifact_id, 2_049, "x")) - .collect::>(); let result = admit_client_evidence(&bundle, &assessment, &payloads); assert!( result.is_err(), "two individually bounded rotations totaling 4,098 records were admitted" ); - let error = result.expect_err("the logical-record cap must apply across the complete bundle"); + 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" diff --git a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs index 8718d93d9..332e127f6 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs @@ -31,6 +31,7 @@ fn source_group(basename: &str) -> &'static str { "PolicyAgent.log" => "client-policy-agent", "CAS.log" => "client-content", "AppIntentEval.log" => "client-app-intent", + "AppEnforce.log" => "client-app-enforce", "CustomVendorHook.log" => "unknown", _ => panic!("authority fixture basename must be declared here"), } @@ -130,6 +131,26 @@ fn admission_rejects_substituted_valid_ccm_bytes_against_the_intake_binding() { ); } +#[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"); @@ -218,7 +239,7 @@ fn intake_content_binding_is_paired_lowercase_and_capture_local() { fn legacy_intake_remains_assessable_but_cannot_admit_bytes() { let bytes = ccm_bytes("legacy policy evidence"); let legacy = artifact( - "legacy", + "policy-approved", "PolicyAgent.log", SccmCoverageState::Captured, true, @@ -231,7 +252,11 @@ fn legacy_intake_remains_assessable_but_cannot_admit_bytes() { assert!(wire["artifacts"][0].get("contentSha256").is_none()); let error = admission_error( - admit_client_evidence(&bundle, &assessment, &[payload("fixture-legacy", bytes)]), + admit_client_evidence( + &bundle, + &assessment, + &[payload("fixture-policy-approved", bytes)], + ), "legacy intake must not authorize caller-supplied bytes", ); assert_eq!( @@ -300,7 +325,7 @@ fn admission_rejects_swapped_duplicate_extra_and_missing_payloads() { &assessment, &[ payload("fixture-policy", policy_bytes.clone()), - payload("fixture-extra", ccm_bytes("extra evidence")), + payload("fixture-policy-two", ccm_bytes("extra evidence")), ], ), "an extra syntactically valid payload identity must fail closed", @@ -343,6 +368,13 @@ fn incomplete_and_capped_sources_fail_locally_without_blocking_bound_policy() { 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( @@ -361,6 +393,9 @@ fn incomplete_and_capped_sources_fail_locally_without_blocking_bound_policy() { 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); } diff --git a/crates/cmtraceopen-parser/src/sccm/client/intake.rs b/crates/cmtraceopen-parser/src/sccm/client/intake.rs index fbe0135d1..8bbc01f3d 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -185,6 +185,17 @@ pub struct SccmClientIntakeArtifact { /// `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, + /// complete `Captured` fragment. This is optional so legacy intake stays + /// assessment-compatible, but admission requires it together with + /// `content_sha256` before caller-supplied bytes can become evidence. + #[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, incomplete, 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 @@ -480,6 +491,8 @@ pub struct SccmClientIntakeFragment { 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)] @@ -544,6 +557,10 @@ struct SccmClientIntakeFragmentWire { 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 { @@ -560,6 +577,8 @@ impl From for SccmClientIntakeFragment { 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, } } } @@ -578,6 +597,8 @@ impl From<&SccmClientIntakeFragment> for SccmClientIntakeFragmentWire { 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(), } } } @@ -835,6 +856,8 @@ pub enum SccmClientIntakeError { 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, } @@ -1125,6 +1148,8 @@ fn fragment_as_intake_artifact(fragment: &SccmClientIntakeFragment) -> SccmClien 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(), } } @@ -1148,6 +1173,8 @@ fn unsupported_as_intake_artifact( rotation_lineage: unsupported.rotation_lineage.clone(), relative_path: unsupported.relative_path.clone(), fragment_complete: unsupported.fragment_complete, + declared_byte_length: None, + content_sha256: None, } } @@ -1313,6 +1340,7 @@ fn validate_bundle(bundle: &SccmClientIntakeBundle) -> Result<(), SccmClientInta 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), @@ -1484,9 +1512,35 @@ fn intake_fragment(source: &SccmClientIntakeArtifact) -> SccmClientIntakeFragmen 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), + }; + + if has_binding + && (source.artifact.coverage != SccmCoverageState::Captured + || !fragment_complete + || 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) @@ -1678,7 +1732,7 @@ fn is_physical_state(coverage: &SccmCoverageState) -> bool { ) } -fn is_safe_artifact_id(value: &str) -> bool { +pub(super) fn is_safe_artifact_id(value: &str) -> bool { if value.is_empty() || value.chars().count() > MAX_ARTIFACT_ID_CHARS { return false; } diff --git a/crates/cmtraceopen-parser/src/sccm/client/mod.rs b/crates/cmtraceopen-parser/src/sccm/client/mod.rs index b2df4278e..6622096f5 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -11,4 +11,8 @@ mod admission_tests; #[cfg(test)] mod authority_contract_tests; +pub use admission::{ + admit_client_evidence, SccmClientAdmittedEvidence, SccmClientCapturedPayload, + SccmClientEvidenceAdmissionError, +}; pub use intake::*; diff --git a/crates/cmtraceopen-parser/tests/sccm_client_admission_authority.rs b/crates/cmtraceopen-parser/tests/sccm_client_admission_authority.rs index 124ac0277..e7f556f4e 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_admission_authority.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_admission_authority.rs @@ -28,7 +28,7 @@ fn public_bytes_only_facade_uses_intake_bound_content_authority() { let bundle = SccmClientIntakeBundle { artifacts: vec![SccmClientIntakeArtifact { artifact: SccmArtifact { - artifact_id: "fixture-public-authority".to_owned(), + artifact_id: "fixture-policy-approved".to_owned(), display_name: "PolicyAgent.log".to_owned(), original_path: None, host: None, @@ -39,7 +39,7 @@ fn public_bytes_only_facade_uses_intake_bound_content_authority() { coverage: SccmCoverageState::Captured, encoding: Some("utf-8".to_owned()), }, - path_fingerprint: Some("synthetic-public-authority".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), @@ -49,7 +49,7 @@ fn public_bytes_only_facade_uses_intake_bound_content_authority() { capture_gaps: Vec::new(), }; let assessment = assess_client_intake(&bundle).expect("public intake is canonical"); - let payload = match SccmClientCapturedPayload::new("fixture-public-authority", bytes) { + let payload = match SccmClientCapturedPayload::new("fixture-policy-approved", bytes) { Ok(payload) => payload, Err(error) => panic!("public payload constructor rejected canonical bytes: {error}"), }; diff --git a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs index c77263fa5..2ebef4a15 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs @@ -224,6 +224,8 @@ fn load_bundle(scenario: &str) -> SccmClientIntakeBundle { 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(), @@ -412,6 +414,8 @@ fn synthetic_artifact(artifact_id: &str, display_name: &str) -> SccmClientIntake rotation_lineage: None, relative_path: Some(relative_path), fragment_complete: Some(true), + declared_byte_length: None, + content_sha256: None, } } @@ -449,6 +453,8 @@ fn opaque_numbered_artifact(number: usize) -> SccmClientIntakeArtifact { "evidence/client-policy-agent/numbered-{number}/PolicyAgent.log.{number}" )), fragment_complete: Some(true), + declared_byte_length: None, + content_sha256: None, } } @@ -1720,6 +1726,8 @@ fn capped_cas_fragment_cannot_claim_complete() { 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!( diff --git a/docs/sccm/preparation/issue-319-client-intake.md b/docs/sccm/preparation/issue-319-client-intake.md index 40c870aca..bd741189c 100644 --- a/docs/sccm/preparation/issue-319-client-intake.md +++ b/docs/sccm/preparation/issue-319-client-intake.md @@ -136,6 +136,15 @@ 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, @@ -210,11 +219,13 @@ remains a separate acceptance gate. file format. - `expected.json` uses `contractState: pureIntakeImplementedNativePending`. `pureAssessment` is the complete typed - executable oracle. `nativeDesignPending` holds byte/limit/digest facts that - remain outside the pure projection, and `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. + 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 From 2b196e05aa7a1610f6f61f6729dbc77ec9110253 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 12:46:17 -0400 Subject: [PATCH 333/422] test(sccm): expose client admission authority gaps --- .../src/sccm/client/admission_tests.rs | 108 +++++++++- .../sccm/client/authority_contract_tests.rs | 188 +++++++++++++++++- .../tests/sccm_client_admission_authority.rs | 19 +- 3 files changed, 306 insertions(+), 9 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs index a42269835..078f991fc 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs @@ -92,6 +92,23 @@ fn ccm_record(message: &str, offset: &str) -> String { ) } +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 bind_artifact_to_bytes(artifact: &mut SccmClientIntakeArtifact, bytes: &[u8]) { artifact.declared_byte_length = Some(bytes.len() as u64); artifact.content_sha256 = Some(digest(bytes)); @@ -218,6 +235,28 @@ fn admission_rejects_missing_extra_duplicate_and_swapped_payloads() { .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(); @@ -239,6 +278,66 @@ fn admission_rejects_payload_digest_and_length_mismatches() { ); } +#[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_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) @@ -260,7 +359,14 @@ fn admission_accepts_the_exact_cap_and_rejects_payload_overflow_before_reassessm let mut overflow = payloads; overflow.push(payload_for("fixture-policy-approved", "+000")); - assert!(admit_client_evidence(&bundle, &assessment, &overflow).is_err()); + 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] diff --git a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs index 332e127f6..7bcaad39b 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs @@ -5,7 +5,11 @@ use super::admission::{ SccmClientEvidenceAdmissionError, }; use super::{assess_client_intake, SccmClientIntakeArtifact, SccmClientIntakeBundle}; -use crate::sccm::{SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmRole, SccmRotation}; +use crate::sccm::{ + extract_keys, SccmArtifact, SccmArtifactFamily, SccmCorrelationKeyKind, SccmCoverageState, + SccmExtractionGapKind, SccmExtractionProfile, SccmExtractionProfileMaturity, SccmKeyConfidence, + SccmRole, SccmRotation, SCCM_EXPERIMENTAL_KEY_PROFILE_ID, +}; fn digest(bytes: &[u8]) -> String { Sha256::digest(bytes) @@ -32,6 +36,8 @@ fn source_group(basename: &str) -> &'static str { "CAS.log" => "client-content", "AppIntentEval.log" => "client-app-intent", "AppEnforce.log" => "client-app-enforce", + "client.msi.log" => "client-ccmsetup", + "ReportingEvents.log" => "client-windows-update-supplemental", "CustomVendorHook.log" => "unknown", _ => panic!("authority fixture basename must be declared here"), } @@ -426,6 +432,186 @@ fn admitted_profile_is_bound_to_the_catalogued_source_family() { ); } +#[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 + .profile_for_artifact("fixture-reporting-supplemental") + .expect("valid authority seal") + .is_none()); +} + +#[test] +fn sealed_policy_profile_extracts_a_low_confidence_assignment_key() { + 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 profile = admitted + .profile_for_artifact("fixture-policy") + .expect("valid authority seal") + .expect("policy evidence has a sealed profile"); + + let result = extract_keys(evidence, profile); + + 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_forged_family_profile_is_rejected_by_key_extraction() { + 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 forged = SccmExtractionProfile { + profile_id: SCCM_EXPERIMENTAL_KEY_PROFILE_ID.to_owned(), + configmgr_version_prefixes: vec!["5.00.9128.".to_owned()], + validated_artifact_families: vec![SccmArtifactFamily::Unknown("callerForged".to_owned())], + selected_configmgr_version: Some("5.00.9128.1000".to_owned()), + maturity: SccmExtractionProfileMaturity::Experimental, + }; + + let result = extract_keys(evidence, &forged); + + 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::AssignmentId) + ); +} + +#[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 evidence = &admitted.evidence().expect("valid authority seal")[0]; + let profile = admitted + .profile_for_artifact("fixture-content") + .expect("valid authority seal") + .expect("content evidence has a sealed family-bound profile"); + + let result = extract_keys(evidence, profile); + + assert_eq!( + profile.validated_artifact_families, + [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")); diff --git a/crates/cmtraceopen-parser/tests/sccm_client_admission_authority.rs b/crates/cmtraceopen-parser/tests/sccm_client_admission_authority.rs index e7f556f4e..3cc585940 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_admission_authority.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_admission_authority.rs @@ -25,6 +25,8 @@ fn bytes() -> 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 { @@ -43,8 +45,8 @@ fn public_bytes_only_facade_uses_intake_bound_content_authority() { rotation_lineage: None, relative_path: Some("evidence/client-policy-agent/current/PolicyAgent.log".to_owned()), fragment_complete: Some(true), - declared_byte_length: Some(bytes.len() as u64), - content_sha256: Some(digest(&bytes)), + declared_byte_length: Some(expected_length), + content_sha256: Some(expected_digest.clone()), }], capture_gaps: Vec::new(), }; @@ -54,10 +56,9 @@ fn public_bytes_only_facade_uses_intake_bound_content_authority() { Err(error) => panic!("public payload constructor rejected canonical bytes: {error}"), }; - assert!( - admit_client_evidence(&bundle, &assessment, &[payload]).is_ok(), - "public callers can obtain opaque authority only for intake-bound bytes" - ); + 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()); @@ -70,6 +71,10 @@ fn public_bytes_only_facade_uses_intake_bound_content_authority() { let projected = assess_client_intake(&round_trip).expect("round trip remains canonical"); assert_eq!( projected.physical_artifacts[0].declared_byte_length, - Some(round_trip.artifacts[0].declared_byte_length.unwrap()) + Some(expected_length) + ); + assert_eq!( + projected.physical_artifacts[0].content_sha256.as_deref(), + Some(expected_digest.as_str()) ); } From 2d18af848854ff9b7d7c9c8eae82ea2deee0a0a9 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 12:51:50 -0400 Subject: [PATCH 334/422] fix(sccm): enforce client evidence source authority --- .../src/sccm/client/admission.rs | 110 +++++++++++------- crates/cmtraceopen-parser/src/sccm/keys.rs | 39 ++++++- 2 files changed, 106 insertions(+), 43 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index 5e96678e8..a7602525b 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -24,9 +24,8 @@ use crate::parser::ccm::scan_logical_records_bounded; use crate::sccm::catalog::classify_artifact_name; use crate::sccm::evidence::SccmRawEvidenceSnapshot; use crate::sccm::{ - SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmEvidence, SccmExtractionProfile, - SccmExtractionProfileMaturity, SccmRole, SccmTimeOrderingState, - SCCM_EXPERIMENTAL_KEY_PROFILE_ID, + SccmArtifact, SccmCoverageState, SccmEvidence, SccmExtractionProfile, SccmRole, + SccmTimeOrderingState, }; use super::{ @@ -240,6 +239,10 @@ pub fn admit_client_evidence( let mut eligible = BTreeMap::new(); 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 || fragment.fragment_complete != Some(true) { @@ -249,20 +252,19 @@ pub fn admit_client_evidence( unbound_complete_captures.insert(fragment.artifact_id.as_str()); continue; } - eligible.insert(fragment.artifact_id.clone(), fragment); + eligible.insert(fragment.artifact_id.clone(), (fragment, classified.family)); } let admitted_source_groups = canonical .groups .iter() .filter(|group| { - !group.fragments.is_empty() + group.fragments.iter().any(is_supported_raw_ccm_fragment) && group.coverage == SccmCoverageState::Captured - && group.fragments.iter().all(|fragment| { - fragment.coverage == SccmCoverageState::Captured - && fragment.fragment_complete == Some(true) - && fragment.declared_byte_length.is_some() - && fragment.content_sha256.is_some() - }) + && group + .fragments + .iter() + .filter(|fragment| is_supported_raw_ccm_fragment(fragment)) + .all(is_bound_complete_capture) }) .map(|group| group.logical_artifact_id.clone()) .collect::>(); @@ -272,9 +274,12 @@ pub fn admit_client_evidence( { return Err(SccmClientEvidenceAdmissionError::MissingContentBinding); } - if payloads.len() != eligible.len() { + 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)); @@ -290,19 +295,17 @@ pub fn admit_client_evidence( if !seen_payload_ids.insert(payload.artifact_id.as_str()) { return Err(SccmClientEvidenceAdmissionError::DuplicatePayload); } - let fragment = eligible + let (fragment, family) = eligible .get(&payload.artifact_id) - .copied() .ok_or(SccmClientEvidenceAdmissionError::ExtraPayload)?; + let fragment = *fragment; validate_payload(payload, fragment)?; - let classified = classify_artifact_name(&fragment.basename, SccmRole::Client); - let family = classified.family; - let mut profile = SccmExtractionProfile::for_version(fragment.configmgr_version.as_deref()); - profile.validated_artifact_families = vec![family.clone()]; - if !classified.supported_for_diagnosis || !is_registered_client_profile(&profile, &family) { - return Err(SccmClientEvidenceAdmissionError::UnregisteredProfile); - } + let profile = SccmExtractionProfile::for_artifact_family( + fragment.configmgr_version.as_deref(), + family, + ) + .ok_or(SccmClientEvidenceAdmissionError::UnregisteredProfile)?; let content = decode_payload(payload, fragment.encoding.as_deref())?; let artifact = artifact_for_fragment(fragment); let scan = scan_logical_records_bounded( @@ -390,6 +393,18 @@ fn validate_payload_budget( Ok(()) } +fn is_supported_raw_ccm_fragment(fragment: &SccmClientIntakeFragment) -> bool { + let classified = classify_artifact_name(&fragment.basename, SccmRole::Client); + classified.supported_for_diagnosis && classified.uses_ccm_records +} + +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() +} + fn validate_payload( payload: &SccmClientCapturedPayload, fragment: &SccmClientIntakeFragment, @@ -451,19 +466,45 @@ fn decode_payload( payload: &SccmClientCapturedPayload, encoding: Option<&str>, ) -> Result { - let encoding = match encoding { - Some("utf-8") => UTF_8, - Some("utf-16le") => UTF_16LE, - Some("utf-16be") => UTF_16BE, - Some("windows-1252") => WINDOWS_1252, + 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 (decoded, _, had_errors) = encoding.decode(&payload.bytes); + 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, +} + +fn recognized_unicode_bom(bytes: &[u8]) -> Option<(UnicodeBom, usize)> { + 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(), @@ -479,21 +520,6 @@ fn artifact_for_fragment(fragment: &SccmClientIntakeFragment) -> SccmArtifact { } } -fn is_registered_client_profile( - profile: &SccmExtractionProfile, - family: &SccmArtifactFamily, -) -> bool { - profile.profile_id == SCCM_EXPERIMENTAL_KEY_PROFILE_ID - && profile.maturity == SccmExtractionProfileMaturity::Experimental - && profile.configmgr_version_prefixes == ["5.00.9128."] - && profile.validated_artifact_families.first() == Some(family) - && profile.validated_artifact_families.len() == 1 - && profile - .selected_configmgr_version - .as_deref() - .is_some_and(|version| version.starts_with("5.00.9128.")) -} - fn compare_evidence(left: &SccmEvidence, right: &SccmEvidence) -> std::cmp::Ordering { ( left.reference.artifact_id.as_str(), diff --git a/crates/cmtraceopen-parser/src/sccm/keys.rs b/crates/cmtraceopen-parser/src/sccm/keys.rs index c1821d450..dd43d444b 100644 --- a/crates/cmtraceopen-parser/src/sccm/keys.rs +++ b/crates/cmtraceopen-parser/src/sccm/keys.rs @@ -2,6 +2,7 @@ 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, @@ -48,6 +49,24 @@ impl SccmExtractionProfile { }, } } + + /// 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, + ) -> Option { + let mut profile = Self::for_version(configmgr_version); + if !is_builtin_experimental_core(&profile) { + return None; + } + profile.validated_artifact_families = vec![family.clone()]; + Some(profile) + } } struct KeyPattern { @@ -231,9 +250,20 @@ fn profile_gap_kind(profile: &SccmExtractionProfile) -> Option bool { + is_builtin_experimental_core(profile) + && match profile.validated_artifact_families.as_slice() { + // Preserve the generic public `for_version` contract while the + // admitted-evidence path always seals a catalog family. + [] => true, + [family] => is_validated_builtin_experimental_family(family), + _ => false, + } +} + +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.validated_artifact_families.is_empty() && profile .selected_configmgr_version .as_deref() @@ -243,6 +273,13 @@ fn is_builtin_experimental(profile: &SccmExtractionProfile) -> bool { }) } +fn is_validated_builtin_experimental_family(family: &SccmArtifactFamily) -> bool { + // Only Policy has executable key-extraction fixtures in the first client + // implementation slice. Expanding this registry requires those fixtures + // and a review; catalog membership alone is never validation authority. + matches!(family, SccmArtifactFamily::ClientPolicy) +} + fn is_canonical_configmgr_version(version: &str) -> bool { let mut component_count = 0; for component in version.split('.') { From aebe81c3f050595be6ce043fa039f5a87043e121 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 12:55:15 -0400 Subject: [PATCH 335/422] test(sccm): expose UTF-32 BOM prefix confusion --- .../src/sccm/client/admission_tests.rs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs index 078f991fc..871a9e64e 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs @@ -109,6 +109,23 @@ fn utf16_with_bom(value: &str, little_endian: bool) -> Vec { 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)); @@ -311,6 +328,35 @@ fn admission_rejects_boms_that_do_not_match_the_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"); From d2bcd121cee40108cb259f91c9674ac6f78cef72 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 12:56:04 -0400 Subject: [PATCH 336/422] fix(sccm): reject unsupported Unicode BOMs --- crates/cmtraceopen-parser/src/sccm/client/admission.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index a7602525b..22a8ca7c6 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -491,10 +491,16 @@ enum UnicodeBom { Utf8, Utf16Le, Utf16Be, + Utf32Le, + Utf32Be, } fn recognized_unicode_bom(bytes: &[u8]) -> Option<(UnicodeBom, usize)> { - if bytes.starts_with(&[0xef, 0xbb, 0xbf]) { + 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)) From 5e5b8ce877c341a5bb1ceb5b6d13ba236d7c5953 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 13:10:11 -0400 Subject: [PATCH 337/422] test(sccm): expose mixed setup coverage authority --- .../sccm/client/authority_contract_tests.rs | 108 +++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs index 7bcaad39b..c1efd36fb 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs @@ -4,7 +4,10 @@ use super::admission::{ admit_client_evidence, SccmClientAdmittedEvidence, SccmClientCapturedPayload, SccmClientEvidenceAdmissionError, }; -use super::{assess_client_intake, SccmClientIntakeArtifact, SccmClientIntakeBundle}; +use super::{ + assess_client_intake, SccmClientIntakeArtifact, SccmClientIntakeBundle, + SccmClientIntakeCaptureGap, +}; use crate::sccm::{ extract_keys, SccmArtifact, SccmArtifactFamily, SccmCorrelationKeyKind, SccmCoverageState, SccmExtractionGapKind, SccmExtractionProfile, SccmExtractionProfileMaturity, SccmKeyConfidence, @@ -36,6 +39,7 @@ fn source_group(basename: &str) -> &'static str { "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", @@ -501,6 +505,108 @@ fn captured_non_ccm_supplement_does_not_block_or_join_policy_admission() { .is_none()); } +#[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 sealed_policy_profile_extracts_a_low_confidence_assignment_key() { let assignment_id = "12345678-1234-1234-1234-123456789abc"; From 3d8584c626396e190e9a4df8db2af8e3f4fd5263 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 13:15:41 -0400 Subject: [PATCH 338/422] fix(sccm): isolate raw CCM group readiness --- .../src/sccm/client/admission.rs | 28 ++++++++++++------- .../src/sccm/client/intake.rs | 10 +++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index 22a8ca7c6..df320c8eb 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -29,9 +29,10 @@ use crate::sccm::{ }; use super::{ - assess_client_intake, intake::is_safe_artifact_id, SccmClientIntakeAssessment, - SccmClientIntakeBundle, SccmClientIntakeError, SccmClientIntakeFragment, - MAX_SCCM_CLIENT_INTAKE_ARTIFACTS, + assess_client_intake, + intake::{is_safe_artifact_id, source_matches_group}, + SccmClientIntakeAssessment, SccmClientIntakeBundle, SccmClientIntakeError, + SccmClientIntakeFragment, MAX_SCCM_CLIENT_INTAKE_ARTIFACTS, }; /// Maximum raw bytes decoded from one client payload at the parser admission @@ -132,11 +133,7 @@ impl SccmClientAdmittedEvidence { logical_artifact_id: &str, ) -> Result<(), SccmClientEvidenceAdmissionError> { match self.source_coverage(logical_artifact_id)? { - Some(SccmCoverageState::Captured) - if self.admitted_source_groups.contains(logical_artifact_id) => - { - Ok(()) - } + Some(_) if self.admitted_source_groups.contains(logical_artifact_id) => Ok(()), Some(_) => Err(SccmClientEvidenceAdmissionError::SourceCoverageUnavailable), None => Err(SccmClientEvidenceAdmissionError::UnknownSourceGroup), } @@ -259,12 +256,19 @@ pub fn admit_client_evidence( .iter() .filter(|group| { group.fragments.iter().any(is_supported_raw_ccm_fragment) - && group.coverage == SccmCoverageState::Captured && 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::>(); @@ -394,7 +398,11 @@ fn validate_payload_budget( } fn is_supported_raw_ccm_fragment(fragment: &SccmClientIntakeFragment) -> bool { - let classified = classify_artifact_name(&fragment.basename, SccmRole::Client); + 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 } diff --git a/crates/cmtraceopen-parser/src/sccm/client/intake.rs b/crates/cmtraceopen-parser/src/sccm/client/intake.rs index 8bbc01f3d..ae4c116cd 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -1464,6 +1464,16 @@ fn matching_groups( .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, From ec38be9aac786ad5e8e2149b9e639ac7ced032ea Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 13:19:52 -0400 Subject: [PATCH 339/422] test(sccm): expose forged policy extraction authority --- .../src/sccm/client/admission_tests.rs | 7 +- .../sccm/client/authority_contract_tests.rs | 95 +++++++++++-------- 2 files changed, 59 insertions(+), 43 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs index 871a9e64e..117662de4 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs @@ -204,9 +204,8 @@ fn admission_seals_canonical_records_from_complete_captured_payloads() { Some(&SccmCoverageState::Captured) ); assert!(admitted - .profile_for_artifact("fixture-policy-agent") - .expect("valid seal") - .is_some()); + .extract_keys_for_artifact("fixture-policy-agent") + .is_ok()); assert!(admitted .require_captured_source("client-policy-agent") .is_ok()); @@ -658,7 +657,7 @@ fn admission_integrity_rejects_test_only_record_profile_and_identity_collisions( profile_mutation.test_only_mutate_first_profile(); assert!(profile_mutation.verify_integrity().is_err()); assert!(profile_mutation - .profile_for_artifact("fixture-policy-agent") + .extract_keys_for_artifact("fixture-policy-agent") .is_err()); let mut collision = diff --git a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs index c1efd36fb..b69208211 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs @@ -1,8 +1,8 @@ use sha2::{Digest, Sha256}; use super::admission::{ - admit_client_evidence, SccmClientAdmittedEvidence, SccmClientCapturedPayload, - SccmClientEvidenceAdmissionError, + admit_client_evidence, SccmClientAdmittedEvidence, SccmClientAdmittedKeyExtraction, + SccmClientCapturedPayload, SccmClientEvidenceAdmissionError, }; use super::{ assess_client_intake, SccmClientIntakeArtifact, SccmClientIntakeBundle, @@ -426,14 +426,14 @@ fn admitted_profile_is_bound_to_the_catalogued_source_family() { Err(error) => panic!("bound policy evidence must be admitted: {error}"), }; - let profile = admitted - .profile_for_artifact("fixture-policy") - .expect("valid authority seal") - .expect("admitted artifact has a profile"); + let extraction = admitted + .extract_keys_for_artifact("fixture-policy") + .expect("admitted artifact has sealed key-extraction authority"); assert_eq!( - profile.validated_artifact_families, - [SccmArtifactFamily::ClientPolicy] + extraction.artifact_family(), + &SccmArtifactFamily::ClientPolicy ); + assert_eq!(extraction.artifact_id(), "fixture-policy"); } #[test] @@ -500,9 +500,8 @@ fn captured_non_ccm_supplement_does_not_block_or_join_policy_admission() { .is_err()); assert_eq!(admitted.evidence().expect("valid authority seal").len(), 1); assert!(admitted - .profile_for_artifact("fixture-reporting-supplemental") - .expect("valid authority seal") - .is_none()); + .extract_keys_for_artifact("fixture-reporting-supplemental") + .is_err()); } #[test] @@ -608,7 +607,7 @@ fn raw_ccm_capture_gap_still_blocks_ccmsetup_group_readiness() { } #[test] -fn sealed_policy_profile_extracts_a_low_confidence_assignment_key() { +fn admitted_policy_extraction_is_sealed_to_the_exact_artifact_and_family() { let assignment_id = "12345678-1234-1234-1234-123456789abc"; let bytes = ccm_bytes(&format!("Assignment ID = {assignment_id}")); let bundle = bundle_with(vec![artifact( @@ -621,14 +620,17 @@ fn sealed_policy_profile_extracts_a_low_confidence_assignment_key() { 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 profile = admitted - .profile_for_artifact("fixture-policy") - .expect("valid authority seal") - .expect("policy evidence has a sealed profile"); - - let result = extract_keys(evidence, profile); + 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); @@ -644,7 +646,7 @@ fn sealed_policy_profile_extracts_a_low_confidence_assignment_key() { } #[test] -fn caller_forged_family_profile_is_rejected_by_key_extraction() { +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( @@ -658,26 +660,44 @@ fn caller_forged_family_profile_is_rejected_by_key_extraction() { 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 forged = SccmExtractionProfile { + 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::Unknown("callerForged".to_owned())], + validated_artifact_families: vec![SccmArtifactFamily::ClientPolicy], selected_configmgr_version: Some("5.00.9128.1000".to_owned()), maturity: SccmExtractionProfileMaturity::Experimental, }; - let result = extract_keys(evidence, &forged); - - assert!(result.keys.is_empty()); - assert_eq!(result.gaps.len(), 1); + let generic_result = extract_keys(evidence, &caller_constructed); + assert_eq!(generic_result.keys.len(), 1); assert_eq!( - result.gaps[0].kind, - SccmExtractionGapKind::UnvalidatedProfile + generic_result.keys[0].kind, + SccmCorrelationKeyKind::AssignmentId ); + + 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!( - result.gaps[0].candidate_kind, - Some(SccmCorrelationKeyKind::AssignmentId) + 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] @@ -694,17 +714,14 @@ fn unregistered_ccm_family_is_admitted_with_an_unvalidated_profile_gap() { let admitted = admit_client_evidence(&bundle, &assessment, &[payload("fixture-content", bytes)]) .expect("raw CCM evidence does not require a validated key-extraction family"); - let evidence = &admitted.evidence().expect("valid authority seal")[0]; - let profile = admitted - .profile_for_artifact("fixture-content") - .expect("valid authority seal") - .expect("content evidence has a sealed family-bound profile"); - - let result = extract_keys(evidence, profile); + 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!( - profile.validated_artifact_families, - [SccmArtifactFamily::ClientContent] + extraction.artifact_family(), + &SccmArtifactFamily::ClientContent ); assert!(result.keys.is_empty()); assert_eq!(result.gaps.len(), 1); From 8d8af0a9b7bc78d95c2e1772bdd444ae8f475f45 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 13:23:35 -0400 Subject: [PATCH 340/422] test(sccm): bind extraction to exact artifact evidence --- .../sccm/client/authority_contract_tests.rs | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs index b69208211..09dd96bf4 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs @@ -609,17 +609,34 @@ fn raw_ccm_capture_gap_still_blocks_ccmsetup_group_readiness() { #[test] fn admitted_policy_extraction_is_sealed_to_the_exact_artifact_and_family() { 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 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-policy", bytes)]) - .expect("bound policy evidence must be admitted"); + 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"); From 37309e9f2e0037e16510eec8c792646c2447d998 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 13:24:43 -0400 Subject: [PATCH 341/422] fix(sccm): seal client key extraction authority --- .../src/sccm/client/admission.rs | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index df320c8eb..646afd9f7 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -24,8 +24,8 @@ use crate::parser::ccm::scan_logical_records_bounded; use crate::sccm::catalog::classify_artifact_name; use crate::sccm::evidence::SccmRawEvidenceSnapshot; use crate::sccm::{ - SccmArtifact, SccmCoverageState, SccmEvidence, SccmExtractionProfile, SccmRole, - SccmTimeOrderingState, + extract_keys, SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmEvidence, + SccmExtractionProfile, SccmKeyExtractionResult, SccmRole, SccmTimeOrderingState, }; use super::{ @@ -88,6 +88,29 @@ pub struct SccmClientAdmittedEvidence { integrity_seal: String, } +/// 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()?; @@ -102,12 +125,33 @@ impl SccmClientAdmittedEvidence { Ok(self.source_coverage.get(logical_artifact_id)) } - pub(crate) fn profile_for_artifact( + pub(crate) fn extract_keys_for_artifact( &self, artifact_id: &str, - ) -> Result, SccmClientEvidenceAdmissionError> { + ) -> Result { self.verify_integrity()?; - Ok(self.profiles_by_artifact.get(artifact_id)) + 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 results = self + .evidence + .iter() + .filter(|evidence| evidence.reference.artifact_id == *sealed_artifact_id) + .map(|evidence| extract_keys(evidence, 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 { @@ -200,6 +244,10 @@ pub enum SccmClientEvidenceAdmissionError { InvalidTimestampProvenance, #[error("client evidence admission selected an unregistered extraction profile")] UnregisteredProfile, + #[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")] From a6bdeeb985de3728ca677167cebd931d2470097f Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 13:44:57 -0400 Subject: [PATCH 342/422] test(sccm): pin unsealed native intake projection --- src-tauri/tests/sccm_client_manifest.rs | 30 ++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src-tauri/tests/sccm_client_manifest.rs b/src-tauri/tests/sccm_client_manifest.rs index 39303a1f2..72446a119 100644 --- a/src-tauri/tests/sccm_client_manifest.rs +++ b/src-tauri/tests/sccm_client_manifest.rs @@ -6,7 +6,10 @@ use app_lib::sccm::{ SccmManifestProvenance, SccmManifestSourceState, MAX_SCCM_MANIFEST_ARTIFACTS, SCCM_MANIFEST_FILE_NAME, }; -use cmtraceopen_parser::sccm::{assess_client_intake, SccmCoverageState, SccmRotation}; +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; @@ -272,15 +275,30 @@ fn validated_v1_reader_projects_one_physical_client_artifact() { } #[test] -fn validated_v1_reader_preserves_a_complete_fragment() { +fn native_projection_preserves_completeness_without_sealing_content_binding() { let temp = tempdir().expect("temporary root"); let bundle_root = temp.path().join("bundle"); - let mut current = physical_artifact(SccmRotation::Current, b"complete-policy-current"); + 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"); - assert_eq!(bundle.artifacts[0].fragment_complete, Some(true)); + 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] @@ -378,7 +396,7 @@ fn reader_rejects_duplicate_artifact_ids() { } #[test] -fn legacy_projection_never_invents_native_capture_gaps() { +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); @@ -411,6 +429,8 @@ fn legacy_projection_never_invents_native_capture_gaps() { 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:{}", From bb4571cc7f15380c5e2ebb39118c1af958af1c2f Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 13:45:24 -0400 Subject: [PATCH 343/422] fix(sccm): keep native intake projection unsealed --- src-tauri/src/sccm/manifest.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src-tauri/src/sccm/manifest.rs b/src-tauri/src/sccm/manifest.rs index 859c38ca3..c77ee7eb6 100644 --- a/src-tauri/src/sccm/manifest.rs +++ b/src-tauri/src/sccm/manifest.rs @@ -121,6 +121,8 @@ fn manifest_to_client_intake_bundle( 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 @@ -867,6 +869,8 @@ fn read_legacy_client_intake_bundle( rotation_lineage: None, relative_path: None, fragment_complete: Some(false), + declared_byte_length: None, + content_sha256: None, }); } let bundle = SccmClientIntakeBundle { From 7da7d8d015a774719e9c3aa2d5de0cd404d7b5c2 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 14:01:31 -0400 Subject: [PATCH 344/422] test(sccm): pin independent admission seal cap --- .../src/sccm/client/admission.rs | 3 +++ .../src/sccm/client/admission_tests.rs | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index 646afd9f7..4c9e0e05a 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -46,6 +46,9 @@ 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 into a deterministic client evidence integrity seal. +/// This is an independent serialization-work cap: JSON escaping may reject a +/// retained-memory-bounded bundle once its escaped serialization exceeds this +/// separate hashing-work bound. pub(crate) const MAX_SCCM_CLIENT_ADMISSION_SEAL_BYTES: usize = 4 * 1024 * 1024; /// Raw, already-captured bytes offered to the one-shot client evidence diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs index 117662de4..68779a490 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs @@ -568,6 +568,27 @@ fn admission_rejects_oversized_payload_work_records_and_retained_evidence() { ); } +#[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(); From d705778e959bad23fa7d6b13361c03544731c068 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 14:24:58 -0400 Subject: [PATCH 345/422] test(sccm): expose admitted authority review gaps --- crates/cmtraceopen-parser/src/parser/ccm.rs | 51 ++++++++++++++++++- .../src/sccm/client/admission_tests.rs | 36 ++++++++++--- .../sccm/client/authority_contract_tests.rs | 10 ++-- 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/crates/cmtraceopen-parser/src/parser/ccm.rs b/crates/cmtraceopen-parser/src/parser/ccm.rs index a2abbc176..1e2360472 100644 --- a/crates/cmtraceopen-parser/src/parser/ccm.rs +++ b/crates/cmtraceopen-parser/src/parser/ccm.rs @@ -12,6 +12,8 @@ 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 = ">> = + const { RefCell::new(None) }; +} + +#[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 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, @@ -382,7 +418,7 @@ pub(crate) fn scan_logical_records_bounded( CcmScanMode::SccmEvidence, Some(max_records), ); - CcmLogicalRecordScan { + let bounded = CcmLogicalRecordScan { records: scan .records .into_iter() @@ -390,7 +426,18 @@ pub(crate) fn scan_logical_records_bounded( .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)] diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs index 68779a490..4f50aa5d0 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs @@ -5,6 +5,7 @@ use super::admission::{ SccmClientEvidenceAdmissionError, }; use super::{assess_client_intake, SccmClientIntakeArtifact, SccmClientIntakeBundle}; +use crate::parser::ccm::{observe_bounded_scans, CcmBoundedScanObservation}; use crate::sccm::{SccmArtifact, SccmCoverageState, SccmRole, SccmRotation}; fn digest(bytes: &[u8]) -> String { @@ -593,9 +594,9 @@ fn admission_applies_the_integrity_seal_cap_independently_of_retained_memory() { fn admission_enforces_logical_record_cap_across_all_payloads() { let mut artifacts = Vec::new(); let mut payloads = Vec::new(); - for number in 1..=2 { + 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(2_049, "x"); + let bytes = repeated_record_bytes(record_count, "x"); artifacts.push(numbered_artifact_bound_to(number, &bytes)); payloads.push(payload_from_bytes(&artifact_id, bytes)); } @@ -606,11 +607,8 @@ fn admission_enforces_logical_record_cap_across_all_payloads() { let assessment = assess_client_intake(&bundle).expect("two captured rotations are canonical intake"); - let result = admit_client_evidence(&bundle, &assessment, &payloads); - assert!( - result.is_err(), - "two individually bounded rotations totaling 4,098 records were admitted" - ); + 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", @@ -619,6 +617,30 @@ fn admission_enforces_logical_record_cap_across_all_payloads() { 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] diff --git a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs index 09dd96bf4..c6328e4c6 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs @@ -686,11 +686,11 @@ fn caller_constructed_policy_profile_cannot_cross_the_admitted_boundary() { }; let generic_result = extract_keys(evidence, &caller_constructed); - assert_eq!(generic_result.keys.len(), 1); - assert_eq!( - generic_result.keys[0].kind, - SccmCorrelationKeyKind::AssignmentId - ); + 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") From 0ff8adf33a0234740123ae5d2450e252576d8a42 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 14:26:11 -0400 Subject: [PATCH 346/422] fix(sccm): reserve family trust for admitted profiles --- .../src/sccm/client/admission.rs | 7 +-- crates/cmtraceopen-parser/src/sccm/keys.rs | 51 +++++++++++++++---- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index 4c9e0e05a..e2a7eddf1 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -23,9 +23,10 @@ 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::keys::extract_keys_from_admitted_profile; use crate::sccm::{ - extract_keys, SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmEvidence, - SccmExtractionProfile, SccmKeyExtractionResult, SccmRole, SccmTimeOrderingState, + SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmEvidence, SccmExtractionProfile, + SccmKeyExtractionResult, SccmRole, SccmTimeOrderingState, }; use super::{ @@ -144,7 +145,7 @@ impl SccmClientAdmittedEvidence { .evidence .iter() .filter(|evidence| evidence.reference.artifact_id == *sealed_artifact_id) - .map(|evidence| extract_keys(evidence, profile)) + .map(|evidence| extract_keys_from_admitted_profile(evidence, profile)) .collect::>(); if results.is_empty() { return Err(SccmClientEvidenceAdmissionError::MissingAdmittedArtifactEvidence); diff --git a/crates/cmtraceopen-parser/src/sccm/keys.rs b/crates/cmtraceopen-parser/src/sccm/keys.rs index dd43d444b..ac70c567b 100644 --- a/crates/cmtraceopen-parser/src/sccm/keys.rs +++ b/crates/cmtraceopen-parser/src/sccm/keys.rs @@ -169,6 +169,27 @@ pub fn normalize_key(kind: SccmCorrelationKeyKind, raw: &str) -> SccmCorrelation pub fn extract_keys( evidence: &SccmEvidence, profile: &SccmExtractionProfile, +) -> SccmKeyExtractionResult { + extract_keys_with_authority(evidence, profile, ExtractionProfileAuthority::Public) +} + +pub(crate) fn extract_keys_from_admitted_profile( + evidence: &SccmEvidence, + profile: &SccmExtractionProfile, +) -> SccmKeyExtractionResult { + extract_keys_with_authority(evidence, profile, ExtractionProfileAuthority::Admitted) +} + +#[derive(Clone, Copy)] +enum ExtractionProfileAuthority { + Public, + Admitted, +} + +fn extract_keys_with_authority( + evidence: &SccmEvidence, + profile: &SccmExtractionProfile, + authority: ExtractionProfileAuthority, ) -> SccmKeyExtractionResult { let candidates = find_candidates(&evidence.message); let mut result = SccmKeyExtractionResult { @@ -177,7 +198,7 @@ pub fn extract_keys( gaps: Vec::new(), }; - if let Some(kind) = profile_gap_kind(profile) { + if let Some(kind) = profile_gap_kind(profile, authority) { if candidates.is_empty() { result.gaps.push(gap_for(kind, profile, evidence, None)); } else { @@ -233,7 +254,10 @@ fn is_bounded_key_value(key: &SccmCorrelationKey) -> bool { && has_at_most_chars(&key.normalized, MAX_SCCM_CORRELATION_KEY_VALUE_CHARS) } -fn profile_gap_kind(profile: &SccmExtractionProfile) -> Option { +fn profile_gap_kind( + profile: &SccmExtractionProfile, + authority: ExtractionProfileAuthority, +) -> Option { if profile.selected_configmgr_version.is_none() { return Some(SccmExtractionGapKind::MissingVersion); } @@ -242,20 +266,29 @@ fn profile_gap_kind(profile: &SccmExtractionProfile) -> Option { Some(SccmExtractionGapKind::UnvalidatedVersion) } - SccmExtractionProfileMaturity::Experimental if is_builtin_experimental(profile) => None, + SccmExtractionProfileMaturity::Experimental + if is_builtin_experimental(profile, authority) => + { + None + } SccmExtractionProfileMaturity::Experimental | SccmExtractionProfileMaturity::Stable => { Some(SccmExtractionGapKind::UnvalidatedProfile) } } } -fn is_builtin_experimental(profile: &SccmExtractionProfile) -> bool { +fn is_builtin_experimental( + profile: &SccmExtractionProfile, + authority: ExtractionProfileAuthority, +) -> bool { is_builtin_experimental_core(profile) - && match profile.validated_artifact_families.as_slice() { - // Preserve the generic public `for_version` contract while the - // admitted-evidence path always seals a catalog family. - [] => true, - [family] => is_validated_builtin_experimental_family(family), + && match (authority, profile.validated_artifact_families.as_slice()) { + // Preserve the generic public `for_version` contract without + // treating a caller-populated family list as admission authority. + (ExtractionProfileAuthority::Public, []) => true, + (ExtractionProfileAuthority::Admitted, [family]) => { + is_validated_builtin_experimental_family(family) + } _ => false, } } From 7d63f88b585a49289dfecb6e2dbe6cfd517a2d90 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 14:26:55 -0400 Subject: [PATCH 347/422] fix(sccm): enforce aggregate CCM record budget --- crates/cmtraceopen-parser/src/parser/ccm.rs | 17 +++++++++-------- .../src/sccm/client/admission.rs | 7 ++----- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/crates/cmtraceopen-parser/src/parser/ccm.rs b/crates/cmtraceopen-parser/src/parser/ccm.rs index 1e2360472..aa859c502 100644 --- a/crates/cmtraceopen-parser/src/parser/ccm.rs +++ b/crates/cmtraceopen-parser/src/parser/ccm.rs @@ -547,15 +547,16 @@ fn scan_ccm_content( let line_end = line_number_for_offset(&line_starts, full_match.end().saturating_sub(1)); if let Some(parsed) = parse_captures(&caps) { if parsed.public_compatible || mode == CcmScanMode::SccmEvidence { - if !build.record_limit_reached() { - build.records.push(parsed.into_logical_record( - build.id_counter, - line_start, - line_end, - file_path, - )); - build.id_counter += 1; + 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(), diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index e2a7eddf1..79b72d692 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -364,11 +364,8 @@ pub fn admit_client_evidence( .ok_or(SccmClientEvidenceAdmissionError::UnregisteredProfile)?; let content = decode_payload(payload, fragment.encoding.as_deref())?; let artifact = artifact_for_fragment(fragment); - let scan = scan_logical_records_bounded( - &content, - &fragment.basename, - MAX_SCCM_CLIENT_ADMISSION_LOGICAL_RECORDS, - ); + let scan = + scan_logical_records_bounded(&content, &fragment.basename, remaining_logical_records); if scan.record_limit_exceeded { return Err(SccmClientEvidenceAdmissionError::LogicalRecordLimitExceeded); } From f21ad297391f33217e7239459d64d4d055576229 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 14:31:23 -0400 Subject: [PATCH 348/422] refactor(sccm): confine family trust to sealed admission --- .../src/sccm/client/admission.rs | 16 +++-- crates/cmtraceopen-parser/src/sccm/keys.rs | 58 +++---------------- 2 files changed, 19 insertions(+), 55 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index 79b72d692..8cc34ecc0 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -23,10 +23,9 @@ 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::keys::extract_keys_from_admitted_profile; use crate::sccm::{ - SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmEvidence, SccmExtractionProfile, - SccmKeyExtractionResult, SccmRole, SccmTimeOrderingState, + extract_keys, SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmEvidence, + SccmExtractionProfile, SccmKeyExtractionResult, SccmRole, SccmTimeOrderingState, }; use super::{ @@ -141,11 +140,20 @@ impl SccmClientAdmittedEvidence { let [artifact_family] = profile.validated_artifact_families.as_slice() else { return Err(SccmClientEvidenceAdmissionError::IntegrityViolation); }; + let mut extraction_profile = profile.clone(); + // Only Policy has executable key-extraction fixtures in the first + // client slice. Clearing this sealed family binding selects the public + // generic extractor without exposing a crate-wide privileged helper; + // the opaque result retains the verified family separately. All other + // families remain explicitly unvalidated. + if matches!(artifact_family, SccmArtifactFamily::ClientPolicy) { + extraction_profile.validated_artifact_families.clear(); + } let results = self .evidence .iter() .filter(|evidence| evidence.reference.artifact_id == *sealed_artifact_id) - .map(|evidence| extract_keys_from_admitted_profile(evidence, profile)) + .map(|evidence| extract_keys(evidence, &extraction_profile)) .collect::>(); if results.is_empty() { return Err(SccmClientEvidenceAdmissionError::MissingAdmittedArtifactEvidence); diff --git a/crates/cmtraceopen-parser/src/sccm/keys.rs b/crates/cmtraceopen-parser/src/sccm/keys.rs index ac70c567b..875c1cf03 100644 --- a/crates/cmtraceopen-parser/src/sccm/keys.rs +++ b/crates/cmtraceopen-parser/src/sccm/keys.rs @@ -169,27 +169,6 @@ pub fn normalize_key(kind: SccmCorrelationKeyKind, raw: &str) -> SccmCorrelation pub fn extract_keys( evidence: &SccmEvidence, profile: &SccmExtractionProfile, -) -> SccmKeyExtractionResult { - extract_keys_with_authority(evidence, profile, ExtractionProfileAuthority::Public) -} - -pub(crate) fn extract_keys_from_admitted_profile( - evidence: &SccmEvidence, - profile: &SccmExtractionProfile, -) -> SccmKeyExtractionResult { - extract_keys_with_authority(evidence, profile, ExtractionProfileAuthority::Admitted) -} - -#[derive(Clone, Copy)] -enum ExtractionProfileAuthority { - Public, - Admitted, -} - -fn extract_keys_with_authority( - evidence: &SccmEvidence, - profile: &SccmExtractionProfile, - authority: ExtractionProfileAuthority, ) -> SccmKeyExtractionResult { let candidates = find_candidates(&evidence.message); let mut result = SccmKeyExtractionResult { @@ -198,7 +177,7 @@ fn extract_keys_with_authority( gaps: Vec::new(), }; - if let Some(kind) = profile_gap_kind(profile, authority) { + if let Some(kind) = profile_gap_kind(profile) { if candidates.is_empty() { result.gaps.push(gap_for(kind, profile, evidence, None)); } else { @@ -254,10 +233,7 @@ fn is_bounded_key_value(key: &SccmCorrelationKey) -> bool { && has_at_most_chars(&key.normalized, MAX_SCCM_CORRELATION_KEY_VALUE_CHARS) } -fn profile_gap_kind( - profile: &SccmExtractionProfile, - authority: ExtractionProfileAuthority, -) -> Option { +fn profile_gap_kind(profile: &SccmExtractionProfile) -> Option { if profile.selected_configmgr_version.is_none() { return Some(SccmExtractionGapKind::MissingVersion); } @@ -266,31 +242,18 @@ fn profile_gap_kind( SccmExtractionProfileMaturity::Unvalidated => { Some(SccmExtractionGapKind::UnvalidatedVersion) } - SccmExtractionProfileMaturity::Experimental - if is_builtin_experimental(profile, authority) => - { - None - } + SccmExtractionProfileMaturity::Experimental if is_builtin_experimental(profile) => None, SccmExtractionProfileMaturity::Experimental | SccmExtractionProfileMaturity::Stable => { Some(SccmExtractionGapKind::UnvalidatedProfile) } } } -fn is_builtin_experimental( - profile: &SccmExtractionProfile, - authority: ExtractionProfileAuthority, -) -> bool { +fn is_builtin_experimental(profile: &SccmExtractionProfile) -> bool { is_builtin_experimental_core(profile) - && match (authority, profile.validated_artifact_families.as_slice()) { - // Preserve the generic public `for_version` contract without - // treating a caller-populated family list as admission authority. - (ExtractionProfileAuthority::Public, []) => true, - (ExtractionProfileAuthority::Admitted, [family]) => { - is_validated_builtin_experimental_family(family) - } - _ => false, - } + // Preserve the generic public `for_version` contract without treating + // a caller-populated family list as admission authority. + && profile.validated_artifact_families.is_empty() } fn is_builtin_experimental_core(profile: &SccmExtractionProfile) -> bool { @@ -306,13 +269,6 @@ fn is_builtin_experimental_core(profile: &SccmExtractionProfile) -> bool { }) } -fn is_validated_builtin_experimental_family(family: &SccmArtifactFamily) -> bool { - // Only Policy has executable key-extraction fixtures in the first client - // implementation slice. Expanding this registry requires those fixtures - // and a review; catalog membership alone is never validation authority. - matches!(family, SccmArtifactFamily::ClientPolicy) -} - fn is_canonical_configmgr_version(version: &str) -> bool { let mut component_count = 0; for component in version.split('.') { From b199a53d92b68b6701ab3e679856ebc9499a035b Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 14:32:01 -0400 Subject: [PATCH 349/422] test(ccm): expose observer cleanup after panic --- crates/cmtraceopen-parser/src/parser/ccm.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/cmtraceopen-parser/src/parser/ccm.rs b/crates/cmtraceopen-parser/src/parser/ccm.rs index aa859c502..2bf703dd6 100644 --- a/crates/cmtraceopen-parser/src/parser/ccm.rs +++ b/crates/cmtraceopen-parser/src/parser/ccm.rs @@ -935,6 +935,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#""#; From f6c458adba054bdba6e3923aae8526332fafa026 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 14:32:31 -0400 Subject: [PATCH 350/422] test(ccm): reset bounded scan observation on unwind --- crates/cmtraceopen-parser/src/parser/ccm.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/cmtraceopen-parser/src/parser/ccm.rs b/crates/cmtraceopen-parser/src/parser/ccm.rs index 2bf703dd6..20f6cbc02 100644 --- a/crates/cmtraceopen-parser/src/parser/ccm.rs +++ b/crates/cmtraceopen-parser/src/parser/ccm.rs @@ -387,6 +387,18 @@ thread_local! { 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, @@ -397,6 +409,7 @@ pub(crate) fn observe_bounded_scans( "bounded scan observation cannot be nested" ); }); + let _cleanup = BoundedScanObservationGuard; let output = operation(); let observations = BOUNDED_SCAN_OBSERVATIONS.with(|observations| { observations From 05f5d5ed2138042871f0bebc3b9f081ffb660e99 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 15:09:54 -0400 Subject: [PATCH 351/422] fix(sccm): isolate missing encoding admission gaps --- .../src/sccm/client/admission.rs | 13 +++++- .../sccm/client/authority_contract_tests.rs | 43 +++++++++++++++++++ .../src/sccm/client/intake.rs | 2 +- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index 8cc34ecc0..b30ca9ae1 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -30,7 +30,7 @@ use crate::sccm::{ use super::{ assess_client_intake, - intake::{is_safe_artifact_id, source_matches_group}, + intake::{is_safe_artifact_id, is_supported_encoding, source_matches_group}, SccmClientIntakeAssessment, SccmClientIntakeBundle, SccmClientIntakeError, SccmClientIntakeFragment, MAX_SCCM_CLIENT_INTAKE_ARTIFACTS, }; @@ -309,6 +309,9 @@ pub fn admit_client_evidence( unbound_complete_captures.insert(fragment.artifact_id.as_str()); continue; } + if !has_supported_payload_encoding(fragment) { + continue; + } eligible.insert(fragment.artifact_id.clone(), (fragment, classified.family)); } let admitted_source_groups = canonical @@ -468,6 +471,14 @@ fn is_bound_complete_capture(fragment: &SccmClientIntakeFragment) -> bool { && 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( diff --git a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs index c6328e4c6..7e81399c2 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs @@ -409,6 +409,49 @@ fn incomplete_and_capped_sources_fail_locally_without_blocking_bound_policy() { 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"); diff --git a/crates/cmtraceopen-parser/src/sccm/client/intake.rs b/crates/cmtraceopen-parser/src/sccm/client/intake.rs index ae4c116cd..749102e33 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -1829,7 +1829,7 @@ fn is_four_ascii_digits(value: &str) -> bool { value.len() == 4 && value.bytes().all(|byte| byte.is_ascii_digit()) } -fn is_supported_encoding(value: &str) -> bool { +pub(super) fn is_supported_encoding(value: &str) -> bool { matches!(value, "utf-8" | "utf-16le" | "utf-16be" | "windows-1252") } From d514b06b488542e781272fc67284423ed000996a Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 3 Aug 2026 23:51:16 -0400 Subject: [PATCH 352/422] feat(sccm): add client inventory compliance metering state machines --- crates/cmtraceopen-parser/src/sccm/catalog.rs | 63 ++ .../src/sccm/client/inventory.rs | 631 ++++++++++++++++++ .../cmtraceopen-parser/src/sccm/client/mod.rs | 5 + crates/cmtraceopen-parser/src/sccm/keys.rs | 25 + crates/cmtraceopen-parser/src/sccm/models.rs | 10 +- .../tests/sccm_client_inventory.rs | 189 ++++++ .../tests/sccm_spine_contract.rs | 72 ++ 7 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/client/inventory.rs create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_inventory.rs diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs index 3ad9008a2..64f0410d0 100644 --- a/crates/cmtraceopen-parser/src/sccm/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -15,6 +15,9 @@ pub enum SccmArtifactFamily { ClientApplication, ClientUpdates, ClientTaskSequence, + ClientInventory, + ClientCompliance, + ClientMetering, SiteComponent, SiteStatus, ManagementPoint, @@ -38,6 +41,9 @@ impl SccmArtifactFamily { 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", @@ -75,6 +81,9 @@ impl<'de> Deserialize<'de> for SccmArtifactFamily { 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, @@ -431,6 +440,60 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ 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: "CIAgent", + logical_name: "ciAgent", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientCompliance, + }, + 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: "StateMessage", + logical_name: "stateMessage", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientCompliance, + }, + CatalogSpec { + basename: "SWMTRReportGen", + logical_name: "swmtrReportGen", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientMetering, + }, CatalogSpec { basename: "sitecomp", logical_name: "sitecomp", 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..360231a3c --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/inventory.rs @@ -0,0 +1,631 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::super::{ + classify_artifact_name, normalize_key, SccmArtifact, SccmArtifactFamily, SccmCorrelationKey, + SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, SccmEvidenceRef, + SccmTimeOrderingState, +}; + +const OBSERVED_VERSION_PREFIX: &str = "5.00.TEST."; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmWorkflow { + Inventory, + Compliance, + Metering, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmPhase { + Collect, + Provider, + Serialize, + Queue, + Evaluate, + Remediate, + Aggregate, + Report, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmTransactionState { + InProgress, + Succeeded, + Failed, + Recovered, + Contradictory, + EvaluatedNonCompliant, + InsufficientEvidence, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmTransaction { + pub transaction_id: String, + pub workflow: SccmWorkflow, + pub profile_id: String, + pub configmgr_version: String, + pub phase: SccmPhase, + pub state: SccmTransactionState, + pub last_successful_phase: Option, + pub keys: Vec, + pub evidence: Vec, + pub coverage_gap_artifact_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmCoverageGap { + pub workflow: SccmWorkflow, + pub artifact_id: String, + pub source_basename: String, + pub state: SccmCoverageState, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmSourceObservation { + pub workflow: SccmWorkflow, + pub artifact_id: String, + pub evidence_id: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientAnalysis { + pub transactions: Vec, + pub coverage: Vec, + pub source_local_observations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct TransactionKey { + workflow: SccmWorkflow, + tuple: String, +} + +#[derive(Debug, Clone)] +struct Fact { + workflow: SccmWorkflow, + phase: SccmPhase, + disposition: Disposition, + terminal: bool, + evidence: SccmEvidence, + keys: Vec, + tuple: String, + profile_id: String, + configmgr_version: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Disposition { + Succeeded, + Failed, + NonCompliant, + Deferred, + Other, +} + +/// Analyze only the three client-extended workflows represented by the observed +/// #325 source catalog. The function accepts normalized evidence and metadata; +/// it never reads files, infers paths, or reparses CCM records. +pub fn analyze_client_extended( + artifacts: &[SccmArtifact], + evidence: &[SccmEvidence], +) -> SccmClientAnalysis { + let mut coverage = Vec::new(); + let mut observations = Vec::new(); + let mut facts = BTreeMap::>::new(); + + for artifact in artifacts { + let Some(context) = artifact_context(artifact) else { + continue; + }; + + if artifact.coverage != SccmCoverageState::Captured { + coverage.push(SccmCoverageGap { + workflow: context.workflow, + artifact_id: artifact.artifact_id.clone(), + source_basename: context.source_basename.to_owned(), + state: artifact.coverage.clone(), + reason: "The admitted source was not captured; this is a coverage gap, not a workflow outcome.".to_owned(), + }); + continue; + } + + let Some(version) = artifact.configmgr_version.as_deref() else { + coverage.push(version_gap(context, artifact)); + continue; + }; + if !version.starts_with(OBSERVED_VERSION_PREFIX) { + coverage.push(version_gap(context, artifact)); + continue; + } + + let profile_id = profile_id(context.workflow); + for item in evidence + .iter() + .filter(|item| item.reference.artifact_id == artifact.artifact_id) + { + let Some(fact) = parse_fact( + context, + profile_id, + version, + artifact, + item, + &mut observations, + ) else { + continue; + }; + facts + .entry(TransactionKey { + workflow: fact.workflow, + tuple: fact.tuple.clone(), + }) + .or_default() + .push(fact); + } + } + + coverage.sort_by(|left, right| { + ( + left.workflow, + &left.artifact_id, + &left.source_basename, + &left.reason, + ) + .cmp(&( + right.workflow, + &right.artifact_id, + &right.source_basename, + &right.reason, + )) + }); + observations.sort_by(|left, right| { + ( + left.workflow, + &left.artifact_id, + &left.evidence_id, + &left.reason, + ) + .cmp(&( + right.workflow, + &right.artifact_id, + &right.evidence_id, + &right.reason, + )) + }); + + let transactions = facts + .into_values() + .map(|mut group| reduce_group(&mut group)) + .collect(); + + SccmClientAnalysis { + transactions, + coverage, + source_local_observations: observations, + } +} + +#[derive(Debug, Clone, Copy)] +struct ArtifactContext { + workflow: SccmWorkflow, + source_basename: &'static str, +} + +fn artifact_context(artifact: &SccmArtifact) -> Option { + let catalog = classify_artifact_name(&artifact.display_name, artifact.role.clone()); + let (workflow, source_basename) = match &catalog.family { + SccmArtifactFamily::ClientInventory => (SccmWorkflow::Inventory, catalog.logical_name), + SccmArtifactFamily::ClientCompliance => (SccmWorkflow::Compliance, catalog.logical_name), + SccmArtifactFamily::ClientMetering => (SccmWorkflow::Metering, catalog.logical_name), + _ => return None, + }; + + let source_basename = match workflow { + SccmWorkflow::Inventory => match source_basename.as_str() { + "inventoryAgent" => "InventoryAgent.log", + "inventoryProvider" => "InventoryProvider.log", + "inventoryAgentProvider" => "InventoryAgentProvider.log", + _ => return None, + }, + SccmWorkflow::Compliance => match source_basename.as_str() { + "ciAgent" => "CIAgent.log", + "ciTaskMgr" => "CITaskMgr.log", + "dcmAgent" => "DCMAgent.log", + "dcmReporting" => "DCMReporting.log", + "stateMessage" => "StateMessage.log", + _ => return None, + }, + SccmWorkflow::Metering => "SWMTRReportGen.log", + }; + + Some(ArtifactContext { + workflow, + source_basename, + }) +} + +fn version_gap(context: ArtifactContext, artifact: &SccmArtifact) -> SccmCoverageGap { + SccmCoverageGap { + workflow: context.workflow, + artifact_id: artifact.artifact_id.clone(), + source_basename: context.source_basename.to_owned(), + state: artifact.coverage.clone(), + reason: "The source version is absent or outside the observed profile; no state claim was promoted.".to_owned(), + } +} + +fn parse_fact( + context: ArtifactContext, + profile_id: &str, + version: &str, + artifact: &SccmArtifact, + evidence: &SccmEvidence, + observations: &mut Vec, +) -> Option { + let phase = + field(&evidence.message, "Phase").and_then(|value| parse_phase(context.workflow, &value)); + let disposition = field(&evidence.message, "Disposition") + .map_or(Disposition::Other, |value| parse_disposition(&value)); + let terminal = field(&evidence.message, "Terminal") + .is_some_and(|value| value.eq_ignore_ascii_case("true")); + let Some(phase) = phase else { + observe( + observations, + context.workflow, + artifact, + evidence, + "The record has no admitted phase for this workflow.", + ); + return None; + }; + if !source_allows_phase(context.source_basename, phase) { + observe( + observations, + context.workflow, + artifact, + evidence, + "The source family cannot establish this phase.", + ); + return None; + } + if field(&evidence.message, "Family") + .is_some_and(|value| !value.eq_ignore_ascii_case(workflow_name(context.workflow))) + { + observe( + observations, + context.workflow, + artifact, + evidence, + "The record explicitly names a different workflow family.", + ); + return None; + } + + let required = required_fields(context.workflow); + let Some(values) = required + .iter() + .map(|label| field(&evidence.message, label)) + .collect::>>() + else { + observe( + observations, + context.workflow, + artifact, + 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.workflow, + artifact, + evidence, + "The workflow key tuple contains an invalid or unbounded value.", + ); + return None; + }; + if context.workflow == SccmWorkflow::Compliance + && disposition == Disposition::NonCompliant + && !field(&evidence.message, "ResultType") + .is_some_and(|value| value.eq_ignore_ascii_case("Evaluation")) + { + observe( + observations, + context.workflow, + artifact, + 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(), + configmgr_version: version.to_owned(), + }) +} + +fn required_fields(workflow: SccmWorkflow) -> &'static [&'static str] { + match workflow { + SccmWorkflow::Inventory => &["InventoryCycleId", "ResourceHandle", "ReportId"], + SccmWorkflow::Compliance => &["CiId", "BaselineId", "StateId", "ResourceHandle"], + SccmWorkflow::Metering => &["MeteringCycleId", "RuleId", "ReportId", "ResourceHandle"], + } +} + +fn make_keys( + workflow: SccmWorkflow, + values: &[String], + profile_id: &str, + evidence: &SccmEvidence, +) -> Option> { + let kinds = match workflow { + SccmWorkflow::Inventory => vec![ + SccmCorrelationKeyKind::InventoryCycleId, + SccmCorrelationKeyKind::ResourceHandle, + SccmCorrelationKeyKind::ReportId, + ], + SccmWorkflow::Compliance => vec![ + SccmCorrelationKeyKind::ComplianceCiId, + SccmCorrelationKeyKind::BaselineId, + SccmCorrelationKeyKind::ComplianceStateId, + SccmCorrelationKeyKind::ResourceHandle, + ], + SccmWorkflow::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.extraction_profile_id = Some(profile_id.to_owned()); + key.evidence = Some(evidence.reference.clone()); + key + }) + }) + .collect() +} + +fn reduce_group(group: &mut [Fact]) -> SccmTransaction { + 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 compliant = terminal.iter().any(|fact| { + fact.disposition == Disposition::Succeeded + && field(&fact.evidence.message, "ResultType") + .is_some_and(|value| value.eq_ignore_ascii_case("Evaluation")) + && field(&fact.evidence.message, "Disposition") + .is_some_and(|value| value.eq_ignore_ascii_case("Compliant")) + }); + + let state = if !failures.is_empty() && !successes.is_empty() { + if ordered_recovery(&failures, &successes) { + SccmTransactionState::Recovered + } else { + SccmTransactionState::Contradictory + } + } else if !failures.is_empty() { + SccmTransactionState::Failed + } else if !noncompliant.is_empty() && compliant { + SccmTransactionState::Contradictory + } else if !noncompliant.is_empty() { + SccmTransactionState::EvaluatedNonCompliant + } else if !successes.is_empty() { + SccmTransactionState::Succeeded + } else { + SccmTransactionState::InProgress + }; + + let last_successful_phase = group + .iter() + .filter(|fact| { + fact.disposition == Disposition::Succeeded + || (fact.workflow == SccmWorkflow::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 phase = terminal + .iter() + .map(|fact| fact.phase) + .max() + .unwrap_or(first.phase); + let transaction_id = format!( + "{}:{}", + workflow_name(first.workflow), + first.tuple.to_ascii_lowercase() + ); + SccmTransaction { + transaction_id, + workflow: first.workflow, + profile_id: first.profile_id.clone(), + configmgr_version: first.configmgr_version.clone(), + phase, + state, + last_successful_phase, + keys: first.keys.clone(), + evidence, + coverage_gap_artifact_ids: Vec::new(), + } +} + +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: SccmWorkflow, value: &str) -> Option { + let phase = match value.to_ascii_lowercase().as_str() { + "collect" => SccmPhase::Collect, + "provider" => SccmPhase::Provider, + "serialize" => SccmPhase::Serialize, + "queue" => SccmPhase::Queue, + "evaluate" => SccmPhase::Evaluate, + "remediate" => SccmPhase::Remediate, + "aggregate" => SccmPhase::Aggregate, + "report" => SccmPhase::Report, + _ => return None, + }; + let valid = match workflow { + SccmWorkflow::Inventory => matches!( + phase, + SccmPhase::Collect + | SccmPhase::Provider + | SccmPhase::Serialize + | SccmPhase::Queue + | SccmPhase::Report + ), + SccmWorkflow::Compliance => { + matches!( + phase, + SccmPhase::Evaluate | SccmPhase::Remediate | SccmPhase::Report + ) + } + SccmWorkflow::Metering => { + matches!( + phase, + SccmPhase::Collect | SccmPhase::Aggregate | SccmPhase::Report + ) + } + }; + valid.then_some(phase) +} + +fn source_allows_phase(source: &str, phase: SccmPhase) -> bool { + match source { + "InventoryAgent.log" => phase == SccmPhase::Collect, + "InventoryProvider.log" => matches!(phase, SccmPhase::Provider | SccmPhase::Serialize), + "InventoryAgentProvider.log" => matches!(phase, SccmPhase::Queue | SccmPhase::Report), + "CIAgent.log" => phase == SccmPhase::Evaluate, + "CITaskMgr.log" => matches!(phase, SccmPhase::Evaluate | SccmPhase::Remediate), + "DCMAgent.log" => phase == SccmPhase::Remediate, + "DCMReporting.log" | "StateMessage.log" => phase == SccmPhase::Report, + "SWMTRReportGen.log" => matches!( + phase, + SccmPhase::Collect | SccmPhase::Aggregate | SccmPhase::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 field(message: &str, label: &str) -> Option { + message.split_whitespace().find_map(|token| { + let (key, value) = token.split_once('=')?; + key.eq_ignore_ascii_case(label).then(|| value.to_owned()) + }) +} + +fn profile_id(workflow: SccmWorkflow) -> &'static str { + match workflow { + SccmWorkflow::Inventory => "sccm-client-inventory-5.00.test-v1", + SccmWorkflow::Compliance => "sccm-client-compliance-5.00.test-v1", + SccmWorkflow::Metering => "sccm-client-metering-5.00.test-v1", + } +} + +fn workflow_name(workflow: SccmWorkflow) -> &'static str { + match workflow { + SccmWorkflow::Inventory => "inventory", + SccmWorkflow::Compliance => "compliance", + SccmWorkflow::Metering => "metering", + } +} + +fn observe( + observations: &mut Vec, + workflow: SccmWorkflow, + artifact: &SccmArtifact, + evidence: &SccmEvidence, + reason: &str, +) { + observations.push(SccmSourceObservation { + workflow, + artifact_id: artifact.artifact_id.clone(), + evidence_id: evidence.evidence_id.clone(), + reason: reason.to_owned(), + }); +} diff --git a/crates/cmtraceopen-parser/src/sccm/client/mod.rs b/crates/cmtraceopen-parser/src/sccm/client/mod.rs index 6622096f5..34993f691 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod admission; mod intake; +mod inventory; #[cfg(test)] mod admission_tests; @@ -16,3 +17,7 @@ pub use admission::{ SccmClientEvidenceAdmissionError, }; pub use intake::*; +pub use inventory::{ + analyze_client_extended, SccmClientAnalysis, SccmCoverageGap, SccmPhase, SccmSourceObservation, + SccmTransaction, SccmTransactionState, SccmWorkflow, +}; diff --git a/crates/cmtraceopen-parser/src/sccm/keys.rs b/crates/cmtraceopen-parser/src/sccm/keys.rs index 875c1cf03..b2e9fc03b 100644 --- a/crates/cmtraceopen-parser/src/sccm/keys.rs +++ b/crates/cmtraceopen-parser/src/sccm/keys.rs @@ -380,6 +380,14 @@ fn normalize_value(kind: &SccmCorrelationKeyKind, raw: &str) -> (String, SccmKey normalize_decimal(trimmed) } SccmCorrelationKeyKind::KbId => normalize_kb_id(trimmed), + 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( @@ -472,6 +480,15 @@ fn normalize_kb_id(value: &str) -> Option { 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, @@ -488,5 +505,13 @@ fn key_kind_order(kind: &SccmCorrelationKeyKind) -> u8 { SccmCorrelationKeyKind::RequestId => 11, SccmCorrelationKeyKind::TopicId => 12, SccmCorrelationKeyKind::StateMessageId => 13, + SccmCorrelationKeyKind::InventoryCycleId => 14, + SccmCorrelationKeyKind::ReportId => 15, + SccmCorrelationKeyKind::ResourceHandle => 16, + SccmCorrelationKeyKind::ComplianceCiId => 17, + SccmCorrelationKeyKind::BaselineId => 18, + SccmCorrelationKeyKind::ComplianceStateId => 19, + SccmCorrelationKeyKind::MeteringCycleId => 20, + SccmCorrelationKeyKind::RuleId => 21, } } diff --git a/crates/cmtraceopen-parser/src/sccm/models.rs b/crates/cmtraceopen-parser/src/sccm/models.rs index 7772e0403..47c4a5e05 100644 --- a/crates/cmtraceopen-parser/src/sccm/models.rs +++ b/crates/cmtraceopen-parser/src/sccm/models.rs @@ -10,7 +10,7 @@ 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, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SccmCoverageState { Captured, @@ -196,6 +196,14 @@ pub enum SccmCorrelationKeyKind { RequestId, TopicId, StateMessageId, + InventoryCycleId, + ReportId, + ResourceHandle, + ComplianceCiId, + BaselineId, + ComplianceStateId, + MeteringCycleId, + RuleId, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 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..b7634c8e4 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs @@ -0,0 +1,189 @@ +use cmtraceopen_parser::sccm::{ + analyze_client_extended, SccmArtifact, SccmCoverageState, SccmEvidence, SccmEvidenceRef, + SccmRole, SccmRotation, SccmTimeOrderingState, SccmTimestamp, SccmTransactionState, + SccmWorkflow, +}; + +fn artifact(id: &str, name: &str, coverage: SccmCoverageState) -> SccmArtifact { + SccmArtifact { + artifact_id: id.to_owned(), + display_name: name.to_owned(), + original_path: None, + host: Some("synthetic-host".to_owned()), + role: SccmRole::Client, + configmgr_version: Some("5.00.TEST.1".to_owned()), + collected_at_utc: Some("2026-07-30T00:00:00Z".to_owned()), + rotation: SccmRotation::Current, + coverage, + encoding: Some("utf-8".to_owned()), + } +} + +fn evidence(artifact_id: &str, id: &str, millis: i64, message: &str) -> SccmEvidence { + SccmEvidence { + evidence_id: id.to_owned(), + reference: SccmEvidenceRef { + artifact_id: artifact_id.to_owned(), + entry_id: id.to_owned(), + line_start: Some(1), + line_end: Some(1), + }, + role: SccmRole::Client, + component: None, + ccm_source_file: None, + message: message.to_owned(), + timestamp: SccmTimestamp { + original_display: Some("00:00:00.000+000".to_owned()), + offset_minutes: Some(0), + utc_millis: Some(millis), + ordering_state: SccmTimeOrderingState::NormalizedUtc, + }, + execution_context: None, + } +} + +#[test] +fn separates_inventory_compliance_and_metering_transactions() { + let artifacts = [ + artifact( + "inventory", + "InventoryAgentProvider.log", + SccmCoverageState::Captured, + ), + artifact( + "compliance", + "DCMReporting.log", + SccmCoverageState::Captured, + ), + artifact( + "metering", + "SWMTRReportGen.log", + SccmCoverageState::Captured, + ), + ]; + let evidence = vec![ + evidence( + "inventory", + "inventory-report", + 1_000, + "Family=inventory InventoryCycleId=INV-CYCLE-001 ResourceHandle=safe:resource:inventory-001 ReportId=INV-REPORT-001 Phase=Report Disposition=Succeeded Terminal=true", + ), + evidence( + "compliance", + "compliance-report", + 1_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", + ), + evidence( + "metering", + "metering-report", + 1_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(&artifacts, &evidence); + + assert_eq!(result.transactions.len(), 3); + assert_eq!( + result + .transactions + .iter() + .map(|transaction| transaction.workflow) + .collect::>(), + vec![ + SccmWorkflow::Inventory, + SccmWorkflow::Compliance, + SccmWorkflow::Metering + ] + ); + assert!(result + .transactions + .iter() + .all(|transaction| transaction.state == SccmTransactionState::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 artifacts = vec![artifact( + "inventory", + "InventoryAgentProvider.log", + SccmCoverageState::Captured, + )]; + let evidence = vec![ + evidence( + "inventory", + "failed", + 1_000, + "InventoryCycleId=INV-CYCLE-002 ResourceHandle=safe:resource:inventory-002 ReportId=INV-REPORT-002 Phase=Report Disposition=Failed Terminal=true", + ), + evidence( + "inventory", + "recovered", + 2_000, + "InventoryCycleId=INV-CYCLE-002 ResourceHandle=safe:resource:inventory-002 ReportId=INV-REPORT-002 Phase=Report Disposition=Succeeded Terminal=true", + ), + evidence( + "inventory", + "contradictory", + 2_000, + "InventoryCycleId=INV-CYCLE-003 ResourceHandle=safe:resource:inventory-003 ReportId=INV-REPORT-003 Phase=Report Disposition=Failed Terminal=true", + ), + evidence( + "inventory", + "contradictory-success", + 2_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(&artifacts, &evidence); + + assert_eq!(result.transactions.len(), 2); + assert_eq!( + result.transactions[0].state, + SccmTransactionState::Recovered + ); + assert_eq!( + result.transactions[1].state, + SccmTransactionState::Contradictory + ); +} + +#[test] +fn keeps_missing_sources_versions_and_keys_as_explicit_gaps() { + let artifacts = [ + artifact("absent", "CIAgent.log", SccmCoverageState::Absent), + artifact( + "unknown-version", + "SWMTRReportGen.log", + SccmCoverageState::Captured, + ), + artifact( + "malformed", + "InventoryAgent.log", + SccmCoverageState::Captured, + ), + ]; + let mut unknown_version = artifacts[1].clone(); + unknown_version.configmgr_version = Some("6.00.0.0".to_owned()); + let artifacts = [artifacts[0].clone(), unknown_version, artifacts[2].clone()]; + let evidence = vec![evidence( + "malformed", + "missing-key", + 1_000, + "InventoryCycleId=INV-CYCLE-004 Phase=Collect Disposition=Succeeded Terminal=false", + )]; + + let result = analyze_client_extended(&artifacts, &evidence); + + assert!(result.transactions.is_empty()); + assert_eq!(result.coverage.len(), 2); + assert_eq!(result.coverage[0].state, SccmCoverageState::Absent); + assert_eq!(result.coverage[1].state, SccmCoverageState::Captured); + assert_eq!(result.source_local_observations.len(), 1); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 1caf44cbf..09ed8797a 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -7307,6 +7307,78 @@ fn expected_catalog_tuples() -> Vec { 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, + ), + ( + "CIAgent.log", + SccmRole::Client, + "ciAgent", + SccmArtifactFamily::ClientCompliance, + 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, + ), + ( + "StateMessage.log", + SccmRole::Client, + "stateMessage", + SccmArtifactFamily::ClientCompliance, + true, + true, + ), + ( + "SWMTRReportGen.log", + SccmRole::Client, + "swmtrReportGen", + SccmArtifactFamily::ClientMetering, + true, + true, + ), ( "sitecomp.log", SccmRole::SiteServer, From 3ce89b60879965b5583bd10384bc945a4eab37ce Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 00:35:48 -0400 Subject: [PATCH 353/422] feat(sccm): start sealed client updates analyzer --- .../cmtraceopen-parser/src/sccm/client/mod.rs | 2 + .../src/sccm/client/updates.rs | 336 ++++++++++++++++++ .../tests/sccm_client_updates.rs | 77 ++++ 3 files changed, 415 insertions(+) create mode 100644 crates/cmtraceopen-parser/src/sccm/client/updates.rs create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_updates.rs diff --git a/crates/cmtraceopen-parser/src/sccm/client/mod.rs b/crates/cmtraceopen-parser/src/sccm/client/mod.rs index 34993f691..fc6e23daf 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod admission; mod intake; mod inventory; +mod updates; #[cfg(test)] mod admission_tests; @@ -21,3 +22,4 @@ pub use inventory::{ analyze_client_extended, SccmClientAnalysis, SccmCoverageGap, SccmPhase, SccmSourceObservation, SccmTransaction, SccmTransactionState, SccmWorkflow, }; +pub use updates::*; 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..607b7a31d --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/updates.rs @@ -0,0 +1,336 @@ +//! 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 serde::Serialize; + +use crate::sccm::{ + SccmConfidence, SccmEvidence, SccmEvidenceRef, SccmFindingClass, SccmKeyConfidence, + 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, + InsufficientEvidence, +} + +#[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: SccmClientUpdatePhase, + 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 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 emitted_counterpart_ready_fact: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmClientUpdatesAnalysis { + pub schema_version: u32, + pub transactions: Vec, + pub findings: Vec, + pub correlation_handoff: SccmClientUpdateCorrelationHandoff, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PhaseDisposition { + Succeeded, + Failed, +} + +#[derive(Debug, Clone)] +struct UpdateFact { + key: SccmClientUpdateKey, + phase: SccmClientUpdatePhase, + disposition: PhaseDisposition, + evidence: SccmEvidenceRef, +} + +/// Reduces sealed, intake-bound client evidence into update transactions. +pub fn analyze_client_updates( + admitted: &SccmClientAdmittedEvidence, +) -> Result { + let mut facts_by_key = BTreeMap::<(String, String), Vec>::new(); + for evidence in admitted.evidence()? { + if let Some(fact) = update_fact(evidence) { + facts_by_key + .entry((fact.key.update_id.clone(), fact.key.ci_id.clone())) + .or_default() + .push(fact); + } + } + + let mut transactions = Vec::new(); + let mut findings = Vec::new(); + for (_, mut facts) in facts_by_key { + facts.sort_by(|left, right| { + left.phase + .cmp(&right.phase) + .then_with(|| left.evidence.artifact_id.cmp(&right.evidence.artifact_id)) + .then_with(|| left.evidence.line_start.cmp(&right.evidence.line_start)) + }); + let Some(last) = facts.last() else { + continue; + }; + let failed = facts + .iter() + .filter(|fact| fact.disposition == PhaseDisposition::Failed) + .min_by_key(|fact| fact.phase); + let decisive = failed.unwrap_or(last); + let state = if failed.is_some() { + SccmClientUpdateState::Failed + } else { + SccmClientUpdateState::Succeeded + }; + let classification = if failed.is_some() { + SccmClientUpdateClassification::ConfirmedFailure + } else { + SccmClientUpdateClassification::Success + }; + let last_successful_phase = facts + .iter() + .filter(|fact| { + fact.disposition == PhaseDisposition::Succeeded && 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 transaction_id = format!("updates:update:{}", decisive.key.update_id); + let transaction = SccmClientUpdateTransaction { + transaction_id: transaction_id.clone(), + key: decisive.key.clone(), + phase: decisive.phase, + state, + last_successful_phase, + classification, + confidence: SccmConfidence::Low, + confidence_ceiling: SccmConfidence::Low, + coverage_gap_artifact_ids: Vec::new(), + next_artifact: None, + evidence: evidence.clone(), + }; + if failed.is_some() { + findings.push(SccmClientUpdateFinding { + finding_id: format!("finding:updates:{}-failure", phase_name(decisive.phase)), + subject_id: transaction_id, + class: SccmFindingClass::ConfirmedFailure, + phase: decisive.phase, + last_successful_phase, + confidence: SccmConfidence::Low, + confidence_ceiling: SccmConfidence::Low, + next_artifact: None, + evidence, + }); + } + transactions.push(transaction); + } + + Ok(SccmClientUpdatesAnalysis { + schema_version: SCCM_CLIENT_UPDATES_ANALYSIS_SCHEMA_VERSION, + transactions, + findings, + 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, + emitted_counterpart_ready_fact: false, + }, + }) +} + +fn update_fact(evidence: &SccmEvidence) -> Option { + let update_id = normalize_update_id(message_field(&evidence.message, "UpdateId")?)?; + let ci_id = safe_value(message_field(&evidence.message, "CIId")?)?; + let (phase, disposition) = phase_disposition(evidence)?; + 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_field(&evidence.message, "ClientHandle"), + site_code: optional_safe_field(&evidence.message, "SiteCode"), + sup_host_handle: optional_safe_field(&evidence.message, "SupHostHandle"), + confidence: SccmKeyConfidence::Exact, + extraction_profile_id: SCCM_EXPERIMENTAL_KEY_PROFILE_ID.to_owned(), + }, + phase, + disposition, + evidence: evidence.reference.clone(), + }) +} + +fn phase_disposition(evidence: &SccmEvidence) -> Option<(SccmClientUpdatePhase, PhaseDisposition)> { + if !evidence + .component + .as_deref() + .is_some_and(|component| component.eq_ignore_ascii_case("ScanAgent")) + { + return None; + } + let message = evidence.message.to_ascii_lowercase(); + if message.contains("scanresult=failed") + || message.contains("scan terminal failure") + || message.contains("scan failed") + { + Some((SccmClientUpdatePhase::Scan, PhaseDisposition::Failed)) + } else if message.contains("scanresult=success") || message.contains("scan succeeded") { + Some((SccmClientUpdatePhase::Scan, PhaseDisposition::Succeeded)) + } else { + None + } +} + +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 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", + } +} 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..dace3bb7f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates.rs @@ -0,0 +1,77 @@ +use cmtraceopen_parser::sccm::client::{ + admit_client_evidence, analyze_client_updates, assess_client_intake, SccmClientCapturedPayload, + SccmClientIntakeArtifact, SccmClientIntakeBundle, SccmClientUpdatePhase, SccmClientUpdateState, +}; +use cmtraceopen_parser::sccm::{SccmArtifact, SccmCoverageState, SccmRole, SccmRotation}; +use sha2::{Digest, Sha256}; + +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() +} + +fn admitted_scan(message: &str) -> cmtraceopen_parser::sccm::client::SccmClientAdmittedEvidence { + let bytes = format!( + "\n" + ) + .into_bytes(); + let bundle = SccmClientIntakeBundle { + artifacts: vec![SccmClientIntakeArtifact { + artifact: SccmArtifact { + artifact_id: "fixture-update-failure".to_owned(), + display_name: "ScanAgent.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-30T04:00:02Z".to_owned()), + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + }, + path_fingerprint: Some("synthetic-update-failure".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::new(), + }; + let assessment = assess_client_intake(&bundle).expect("canonical update intake"); + let payload = + SccmClientCapturedPayload::new("fixture-update-failure", bytes).expect("bounded payload"); + admit_client_evidence(&bundle, &assessment, &[payload]).expect("sealed update evidence") +} + +#[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!(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); +} From 8f7bf6a2704fbb4c48f9605e109f7ae8253f8615 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 00:41:13 -0400 Subject: [PATCH 354/422] feat(sccm): reduce client update phases --- .../src/sccm/client/updates.rs | 165 +++++++++-- .../tests/sccm_client_updates.rs | 260 ++++++++++++++++-- 2 files changed, 388 insertions(+), 37 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/updates.rs b/crates/cmtraceopen-parser/src/sccm/client/updates.rs index 607b7a31d..46e6beca1 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/updates.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/updates.rs @@ -125,6 +125,7 @@ pub struct SccmClientUpdatesAnalysis { enum PhaseDisposition { Succeeded, Failed, + Deferred, } #[derive(Debug, Clone)] @@ -165,14 +166,22 @@ pub fn analyze_client_updates( .iter() .filter(|fact| fact.disposition == PhaseDisposition::Failed) .min_by_key(|fact| fact.phase); - let decisive = failed.unwrap_or(last); + let deferred = facts + .iter() + .filter(|fact| fact.disposition == PhaseDisposition::Deferred) + .max_by_key(|fact| fact.phase); + let decisive = failed.or(deferred).unwrap_or(last); let state = if failed.is_some() { SccmClientUpdateState::Failed + } else if deferred.is_some() { + SccmClientUpdateState::BlockedOrDeferred } else { SccmClientUpdateState::Succeeded }; let classification = if failed.is_some() { SccmClientUpdateClassification::ConfirmedFailure + } else if deferred.is_some() { + SccmClientUpdateClassification::BlockedOrDeferred } else { SccmClientUpdateClassification::Success }; @@ -209,11 +218,23 @@ pub fn analyze_client_updates( next_artifact: None, evidence: evidence.clone(), }; - if failed.is_some() { + if failed.is_some() || deferred.is_some() { findings.push(SccmClientUpdateFinding { - finding_id: format!("finding:updates:{}-failure", phase_name(decisive.phase)), + finding_id: format!( + "finding:updates:{}-{}", + phase_name(decisive.phase), + if failed.is_some() { + "failure" + } else { + "deferred" + } + ), subject_id: transaction_id, - class: SccmFindingClass::ConfirmedFailure, + class: if failed.is_some() { + SccmFindingClass::ConfirmedFailure + } else { + SccmFindingClass::BlockedOrDeferred + }, phase: decisive.phase, last_successful_phase, confidence: SccmConfidence::Low, @@ -264,24 +285,130 @@ fn update_fact(evidence: &SccmEvidence) -> Option { } fn phase_disposition(evidence: &SccmEvidence) -> Option<(SccmClientUpdatePhase, PhaseDisposition)> { - if !evidence - .component - .as_deref() - .is_some_and(|component| component.eq_ignore_ascii_case("ScanAgent")) - { - return None; - } + let component = evidence.component.as_deref()?; let message = evidence.message.to_ascii_lowercase(); - if message.contains("scanresult=failed") - || message.contains("scan terminal failure") - || message.contains("scan failed") + 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 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") { - Some((SccmClientUpdatePhase::Scan, PhaseDisposition::Failed)) - } else if message.contains("scanresult=success") || message.contains("scan succeeded") { - Some((SccmClientUpdatePhase::Scan, PhaseDisposition::Succeeded)) + ( + 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 { - None - } + 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> { diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates.rs index dace3bb7f..1dab38f64 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates.rs @@ -15,40 +15,84 @@ fn sha256(bytes: &[u8]) -> String { .collect() } -fn admitted_scan(message: &str) -> cmtraceopen_parser::sccm::client::SccmClientAdmittedEvidence { - let bytes = format!( - "\n" - ) - .into_bytes(); - let bundle = SccmClientIntakeBundle { - artifacts: vec![SccmClientIntakeArtifact { +#[derive(Clone)] +struct Record<'a> { + id: &'a str, + basename: &'a str, + group: &'a str, + component: &'a str, + time: &'a str, + message: String, +} + +fn admitted( + records: &[Record<'_>], +) -> cmtraceopen_parser::sccm::client::SccmClientAdmittedEvidence { + let mut artifacts = Vec::new(); + let mut payloads = Vec::new(); + for record in records { + let artifact_id = format!("fixture-{}", record.id); + let path_fingerprint = format!("synthetic-{}", record.id); + let bytes = format!( + "\n", + record.message, record.time, record.component + ) + .into_bytes(); + artifacts.push(SccmClientIntakeArtifact { artifact: SccmArtifact { - artifact_id: "fixture-update-failure".to_owned(), - display_name: "ScanAgent.log".to_owned(), + 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-30T04:00:02Z".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-failure".to_owned()), + path_fingerprint: Some(path_fingerprint), rotation_lineage: None, - relative_path: Some("evidence/client-updates/current/ScanAgent.log".to_owned()), + relative_path: Some(format!( + "evidence/{}/current/{}", + 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"); - let payload = - SccmClientCapturedPayload::new("fixture-update-failure", bytes).expect("bounded payload"); - admit_client_evidence(&bundle, &assessment, &[payload]).expect("sealed update evidence") + 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" + ) } #[test] @@ -75,3 +119,183 @@ fn scan_failure_uses_sealed_exact_key_evidence_without_server_cause() { 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); +} + +#[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!( + analysis.findings[0].class, + cmtraceopen_parser::sccm::SccmFindingClass::BlockedOrDeferred + ); +} + +#[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") + ); +} From 5d3ca683d5be3af0fb3e7e273102701b12666777 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 01:07:20 -0400 Subject: [PATCH 355/422] feat(sccm): complete client updates diagnosis --- crates/cmtraceopen-parser/src/sccm/catalog.rs | 41 +- .../src/sccm/client/admission.rs | 39 ++ .../src/sccm/client/intake.rs | 19 +- .../src/sccm/client/updates.rs | 480 ++++++++++++++++-- .../client/intake/access-denied/expected.json | 36 ++ .../sccm/client/intake/capped/expected.json | 36 ++ .../client/intake/collision/expected.json | 36 ++ .../current/ServiceWindowManager.log | 1 + .../current/RebootCoordinator.log | 1 + .../sccm/client/intake/complete/expected.json | 65 ++- .../sccm/client/intake/complete/manifest.json | 2 + .../client/intake/missing-root/expected.json | 38 ++ .../client/intake/rotations/expected.json | 36 ++ .../tests/sccm_client_intake.rs | 11 +- .../tests/sccm_client_updates.rs | 369 +++++++++++++- .../tests/sccm_spine_contract.rs | 24 + 16 files changed, 1196 insertions(+), 38 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-maintenance-window/current/ServiceWindowManager.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-reboot/current/RebootCoordinator.log diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs index 64f0410d0..ddf8faaa2 100644 --- a/crates/cmtraceopen-parser/src/sccm/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -189,7 +189,11 @@ const CLIENT_SOURCE_MEMBERSHIPS: &[SccmClientSourceMembership] = &[ }, SccmClientSourceMembership { basename: "LocationServices.log", - logical_artifact_ids: &["client-location", "client-content"], + logical_artifact_ids: &[ + "client-location", + "client-content", + "client-location-services-shared", + ], }, SccmClientSourceMembership { basename: "PolicyAgent.log", @@ -243,6 +247,18 @@ const CLIENT_SOURCE_MEMBERSHIPS: &[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"], @@ -428,6 +444,24 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ 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", @@ -693,7 +727,10 @@ fn declared_catalog_entry(entry: &CatalogSpec, role: SccmRole) -> SccmSourceCata } fn catalog_entry_uses_ccm_records(entry: &CatalogSpec) -> bool { - !matches!(entry.logical_name, "clientMsi" | "reportingEvents") + !matches!( + entry.logical_name, + "clientMsi" | "reportingEvents" | "componentBasedServicing" + ) } struct ParsedArtifactName<'a> { diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index b30ca9ae1..1dae2976e 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -86,6 +86,7 @@ impl SccmClientCapturedPayload { pub struct SccmClientAdmittedEvidence { evidence: Vec, source_coverage: BTreeMap, + source_coverage_by_basename: BTreeMap, admitted_source_groups: BTreeSet, profiles_by_artifact: BTreeMap, integrity_seal: String, @@ -128,6 +129,14 @@ impl SccmClientAdmittedEvidence { 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 extract_keys_for_artifact( &self, artifact_id: &str, @@ -174,6 +183,7 @@ impl SccmClientAdmittedEvidence { let recomputed = compute_integrity_seal( &self.evidence, &self.source_coverage, + &self.source_coverage_by_basename, &self.admitted_source_groups, &self.profiles_by_artifact, )?; @@ -293,6 +303,18 @@ pub fn admit_client_evidence( .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) { + source_coverage_by_basename + .entry(fragment.basename.clone()) + .and_modify(|coverage| { + if source_coverage_priority(&fragment.coverage) > source_coverage_priority(coverage) + { + *coverage = fragment.coverage.clone(); + } + }) + .or_insert_with(|| fragment.coverage.clone()); + } let mut eligible = BTreeMap::new(); let mut unbound_complete_captures = BTreeSet::new(); for fragment in &canonical.physical_artifacts { @@ -427,12 +449,14 @@ pub fn admit_client_evidence( let integrity_seal = compute_integrity_seal( &evidence, &source_coverage, + &source_coverage_by_basename, &admitted_source_groups, &profiles_by_artifact, )?; Ok(SccmClientAdmittedEvidence { evidence, source_coverage, + source_coverage_by_basename, admitted_source_groups, profiles_by_artifact, integrity_seal, @@ -624,6 +648,7 @@ fn compare_evidence(left: &SccmEvidence, right: &SccmEvidence) -> std::cmp::Orde struct IntegrityProjection<'a> { evidence: &'a [SccmEvidence], source_coverage: &'a BTreeMap, + source_coverage_by_basename: &'a BTreeMap, admitted_source_groups: &'a BTreeSet, profile_assignments: &'a BTreeMap<&'a str, usize>, profiles: &'a [&'a SccmExtractionProfile], @@ -678,6 +703,7 @@ impl Write for BoundedIntegrityWriter { fn compute_integrity_seal( evidence: &[SccmEvidence], source_coverage: &BTreeMap, + source_coverage_by_basename: &BTreeMap, admitted_source_groups: &BTreeSet, profiles_by_artifact: &BTreeMap, ) -> Result { @@ -709,6 +735,7 @@ fn compute_integrity_seal( &IntegrityProjection { evidence, source_coverage, + source_coverage_by_basename, admitted_source_groups, profile_assignments: &profile_assignments, profiles: &unique_profiles, @@ -724,6 +751,18 @@ fn compute_integrity_seal( 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() diff --git a/crates/cmtraceopen-parser/src/sccm/client/intake.rs b/crates/cmtraceopen-parser/src/sccm/client/intake.rs index 749102e33..f12263fb4 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -873,6 +873,8 @@ const HEALTH: &[SccmClientWorkflow] = &[SccmClientWorkflow::Health]; const POLICY: &[SccmClientWorkflow] = &[SccmClientWorkflow::Policy]; const DEPLOYMENT: &[SccmClientWorkflow] = &[SccmClientWorkflow::Deployment]; const UPDATES: &[SccmClientWorkflow] = &[SccmClientWorkflow::Updates]; +const DEPLOYMENT_UPDATES: &[SccmClientWorkflow] = + &[SccmClientWorkflow::Deployment, SccmClientWorkflow::Updates]; const HEALTH_DEPLOYMENT: &[SccmClientWorkflow] = &[SccmClientWorkflow::Health, SccmClientWorkflow::Deployment]; @@ -894,7 +896,7 @@ const CLIENT_SOURCE_GROUPS: &[ClientSourceGroupSpec] = &[ }, ClientSourceGroupSpec { logical_artifact_id: "client-content", - workflows: DEPLOYMENT, + workflows: DEPLOYMENT_UPDATES, requiredness: SccmClientSourceRequiredness::Required, }, ClientSourceGroupSpec { @@ -912,6 +914,16 @@ const CLIENT_SOURCE_GROUPS: &[ClientSourceGroupSpec] = &[ 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-policy-agent", workflows: POLICY, @@ -922,6 +934,11 @@ const CLIENT_SOURCE_GROUPS: &[ClientSourceGroupSpec] = &[ workflows: POLICY, requiredness: SccmClientSourceRequiredness::Required, }, + ClientSourceGroupSpec { + logical_artifact_id: "client-reboot", + workflows: UPDATES, + requiredness: SccmClientSourceRequiredness::Required, + }, ClientSourceGroupSpec { logical_artifact_id: "client-updates", workflows: UPDATES, diff --git a/crates/cmtraceopen-parser/src/sccm/client/updates.rs b/crates/cmtraceopen-parser/src/sccm/client/updates.rs index 46e6beca1..2bb047484 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/updates.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/updates.rs @@ -6,11 +6,12 @@ use std::collections::BTreeMap; +use chrono::{DateTime, SecondsFormat, Utc}; use serde::Serialize; use crate::sccm::{ - SccmConfidence, SccmEvidence, SccmEvidenceRef, SccmFindingClass, SccmKeyConfidence, - SCCM_EXPERIMENTAL_KEY_PROFILE_ID, + SccmConfidence, SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmFindingClass, + SccmKeyConfidence, SccmTimeOrderingState, SccmTimestamp, SCCM_EXPERIMENTAL_KEY_PROFILE_ID, }; use super::{SccmClientAdmittedEvidence, SccmClientEvidenceAdmissionError}; @@ -36,7 +37,8 @@ pub enum SccmClientUpdateState { Succeeded, Failed, BlockedOrDeferred, - InsufficientEvidence, + Incomplete, + Contradictory, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -92,7 +94,7 @@ pub struct SccmClientUpdateFinding { pub finding_id: String, pub subject_id: String, pub class: SccmFindingClass, - pub phase: SccmClientUpdatePhase, + pub phase: Option, pub last_successful_phase: Option, pub confidence: SccmConfidence, pub confidence_ceiling: SccmConfidence, @@ -100,6 +102,30 @@ pub struct SccmClientUpdateFinding { 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: SccmCoverageState, +} + +#[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 { @@ -109,7 +135,47 @@ pub struct SccmClientUpdateCorrelationHandoff { 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)] @@ -117,8 +183,12 @@ pub struct SccmClientUpdateCorrelationHandoff { 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)] @@ -126,6 +196,7 @@ enum PhaseDisposition { Succeeded, Failed, Deferred, + Contradictory, } #[derive(Debug, Clone)] @@ -134,61 +205,141 @@ struct UpdateFact { phase: SccmClientUpdatePhase, disposition: PhaseDisposition, evidence: SccmEvidenceRef, + timestamp: SccmTimestamp, + location_services_source: bool, } /// 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::<(String, String), Vec>::new(); + let mut observations = Vec::new(); for evidence in admitted.evidence()? { if let Some(fact) = update_fact(evidence) { facts_by_key .entry((fact.key.update_id.clone(), fact.key.ci_id.clone())) .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 Some(last) = facts.last() else { + let mut effective_by_phase = BTreeMap::new(); + for fact in &facts { + effective_by_phase.insert(fact.phase, fact); + } + let Some(last) = effective_by_phase.values().next_back().copied() else { continue; }; - let failed = facts - .iter() + if let Some(counterpart) = facts.iter().find_map(counterpart_ready_fact) { + counterpart_ready_facts.push(counterpart); + } + let failed = effective_by_phase + .values() + .copied() .filter(|fact| fact.disposition == PhaseDisposition::Failed) .min_by_key(|fact| fact.phase); - let deferred = facts - .iter() + let deferred = effective_by_phase + .values() + .copied() .filter(|fact| fact.disposition == PhaseDisposition::Deferred) .max_by_key(|fact| fact.phase); - let decisive = failed.or(deferred).unwrap_or(last); + let contradictory = effective_by_phase + .values() + .copied() + .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::ConfirmedFailure + SccmClientUpdateClassification::Symptom } else if deferred.is_some() { SccmClientUpdateClassification::BlockedOrDeferred + } else if contradictory.is_some() || incomplete_phase.is_some() { + SccmClientUpdateClassification::InsufficientEvidence } else { SccmClientUpdateClassification::Success }; - let last_successful_phase = facts - .iter() + let decisive_is_success = failed.is_none() && deferred.is_none() && contradictory.is_none(); + let last_successful_phase = effective_by_phase + .values() + .copied() .filter(|fact| { - fact.disposition == PhaseDisposition::Succeeded && fact.phase <= decisive.phase + fact.disposition == PhaseDisposition::Succeeded + && (fact.phase < decisive.phase + || (decisive_is_success && fact.phase == decisive.phase)) }) .map(|fact| fact.phase) .max(); @@ -204,52 +355,126 @@ pub fn analyze_client_updates( .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 transaction_id = format!("updates:update:{}", decisive.key.update_id); let transaction = SccmClientUpdateTransaction { transaction_id: transaction_id.clone(), key: decisive.key.clone(), - phase: decisive.phase, + phase: output_phase, state, last_successful_phase, classification, confidence: SccmConfidence::Low, confidence_ceiling: SccmConfidence::Low, - coverage_gap_artifact_ids: Vec::new(), - next_artifact: None, + coverage_gap_artifact_ids, + next_artifact: next_artifact.clone(), evidence: evidence.clone(), }; - if failed.is_some() || deferred.is_some() { + if failed.is_some() + || deferred.is_some() + || contradictory.is_some() + || incomplete_phase.is_some() + { findings.push(SccmClientUpdateFinding { finding_id: format!( - "finding:updates:{}-{}", - phase_name(decisive.phase), + "finding:updates:{}:{}-{}", + decisive.key.update_id, + phase_name(output_phase), if failed.is_some() { "failure" - } else { + } else if deferred.is_some() { "deferred" + } else if contradictory.is_some() { + "contradictory" + } else { + "incomplete" } ), subject_id: transaction_id, class: if failed.is_some() { - SccmFindingClass::ConfirmedFailure - } else { + SccmFindingClass::Symptom + } else if deferred.is_some() { SccmFindingClass::BlockedOrDeferred + } else { + SccmFindingClass::InsufficientEvidence }, - phase: decisive.phase, + phase: Some(output_phase), last_successful_phase, confidence: SccmConfidence::Low, confidence_ceiling: SccmConfidence::Low, - next_artifact: None, + 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(), @@ -257,11 +482,128 @@ pub fn analyze_client_updates( time_only_eligible: false, topology_compatibility_evaluated: false, server_cause_claimed: false, - emitted_counterpart_ready_fact: 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 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)? { + coverage.push(SccmClientUpdateCoverage { + logical_artifact_id: logical_artifact_id.to_owned(), + state: state.clone(), + }); + } + } + } + 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_and(|coverage| *coverage != SccmCoverageState::Captured) + && !phases.contains(&phase) + { + phases.push(phase); + } + } + Ok(phases) +} + fn update_fact(evidence: &SccmEvidence) -> Option { let update_id = normalize_update_id(message_field(&evidence.message, "UpdateId")?)?; let ci_id = safe_value(message_field(&evidence.message, "CIId")?)?; @@ -272,15 +614,59 @@ fn update_fact(evidence: &SccmEvidence) -> Option { ci_id, content_id: optional_safe_field(&evidence.message, "ContentId"), update_job_id: optional_safe_field(&evidence.message, "UpdateJobId"), - client_handle: optional_safe_field(&evidence.message, "ClientHandle"), + client_handle: optional_safe_handle(&evidence.message, "ClientHandle"), site_code: optional_safe_field(&evidence.message, "SiteCode"), - sup_host_handle: optional_safe_field(&evidence.message, "SupHostHandle"), - confidence: SccmKeyConfidence::Exact, + sup_host_handle: optional_safe_handle(&evidence.message, "SupHostHandle"), + 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: evidence + .component + .as_deref() + .is_some_and(|component| component.eq_ignore_ascii_case("LocationServices")), + }) +} + +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?, + }, }) } @@ -305,6 +691,20 @@ fn phase_disposition(evidence: &SccmEvidence) -> Option<(SccmClientUpdatePhase, "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) @@ -422,6 +822,10 @@ fn optional_safe_field(message: &str, label: &str) -> Option { safe_value(message_field(message, label)?) } +fn optional_safe_handle(message: &str, label: &str) -> Option { + safe_value(message_field(message, label)?).filter(|value| value.starts_with("safe:")) +} + fn safe_value(value: &str) -> Option { let value = value.trim_matches(|character| matches!(character, '{' | '}' | ',' | ';')); (!value.is_empty() @@ -461,3 +865,21 @@ fn phase_name(phase: SccmClientUpdatePhase) -> &'static str { 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." + ), + } +} 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 index 62f9a3dbb..b220e2ad9 100644 --- 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 @@ -39,6 +39,16 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-location-services-shared", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-maintenance-window", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-policy-agent", "coverage": "accessDenied", @@ -53,6 +63,11 @@ "fixture-access-policy-state-root-a-current" ] }, + { + "logicalArtifactId": "client-reboot", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-updates", "coverage": "absent", @@ -148,6 +163,20 @@ "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-policy-agent", "artifactId": "fixture-access-policy-agent-root-a-current", @@ -155,6 +184,13 @@ "coverage": "accessDenied", "reason": "Access was denied for client source PolicyAgent.log." }, + { + "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, diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json index 75ba507ff..7ac048b2e 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json @@ -41,6 +41,16 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-location-services-shared", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-maintenance-window", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-policy-agent", "coverage": "absent", @@ -51,6 +61,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-reboot", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-updates", "coverage": "absent", @@ -132,6 +147,20 @@ "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-policy-agent", "artifactId": null, @@ -146,6 +175,13 @@ "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, 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 index 472e85897..8a0597efd 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/expected.json @@ -42,6 +42,16 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-location-services-shared", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-maintenance-window", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-policy-agent", "coverage": "absent", @@ -52,6 +62,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-reboot", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-updates", "coverage": "absent", @@ -141,6 +156,20 @@ "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-policy-agent", "artifactId": null, @@ -155,6 +184,13 @@ "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, 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-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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json index bc7f6f3c0..91196012c 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json @@ -54,6 +54,20 @@ "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-policy-agent", "coverage": "captured", @@ -68,6 +82,13 @@ "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", @@ -237,6 +258,34 @@ "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": [ @@ -250,7 +299,9 @@ "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-updates-root-a-current", + "fixture-complete-updates-root-a-numbered-01", + "fixture-complete-updates-root-a-numbered-02" ], "unsupportedArtifacts": [], "coverageGaps": [] @@ -322,6 +373,18 @@ "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 } ] }, 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 index a2a082536..70d983aac 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/manifest.json @@ -17,8 +17,10 @@ {"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-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"} ] 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 index 9e7bfcf08..05cfd4a4a 100644 --- 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 @@ -54,6 +54,18 @@ "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-policy-agent", "coverage": "absent", @@ -68,6 +80,11 @@ "fixture-missing-policy-state-current" ] }, + { + "logicalArtifactId": "client-reboot", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-updates", "coverage": "absent", @@ -298,6 +315,20 @@ "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-policy-agent", "artifactId": "fixture-missing-policy-agent-current", @@ -312,6 +343,13 @@ "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", 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 index 44b76df0d..fccea92a0 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json @@ -43,6 +43,16 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-location-services-shared", + "coverage": "absent", + "fragmentArtifactIds": [] + }, + { + "logicalArtifactId": "client-maintenance-window", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-policy-agent", "coverage": "absent", @@ -53,6 +63,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-reboot", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-updates", "coverage": "absent", @@ -161,6 +176,20 @@ "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-policy-agent", "artifactId": null, @@ -175,6 +204,13 @@ "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, diff --git a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs index 2ebef4a15..091beafa9 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs @@ -1195,7 +1195,10 @@ fn every_declared_client_basename_is_supported_by_the_authoritative_catalog() { ); assert_eq!( classified.uses_ccm_records, - !matches!(basename.as_str(), "client.msi.log" | "ReportingEvents.log"), + !matches!( + basename.as_str(), + "client.msi.log" | "ReportingEvents.log" | "CBS.log" + ), "the shared catalog must not route a non-CCM supplement through raw CCM" ); } @@ -1481,7 +1484,7 @@ 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(), 11); + assert_eq!(declared.len(), 14); assert_eq!(intake.groups.len(), declared.len()); assert!(intake .groups @@ -1660,8 +1663,8 @@ fn missing_access_denied_and_capped_sources_remain_exact_coverage_states() { .all(|group| group.coverage == SccmCoverageState::Absent)); assert_eq!( missing.coverage_gaps.len(), - 12, - "the shared LocationServices declaration contributes one gap to each consumer group" + 15, + "the shared LocationServices declaration contributes one gap to each consumer group, while maintenance and reboot remain explicit" ); assert_eq!( missing diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates.rs index 1dab38f64..a68bd37a6 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates.rs @@ -2,8 +2,13 @@ use cmtraceopen_parser::sccm::client::{ admit_client_evidence, analyze_client_updates, assess_client_intake, SccmClientCapturedPayload, SccmClientIntakeArtifact, SccmClientIntakeBundle, SccmClientUpdatePhase, SccmClientUpdateState, }; -use cmtraceopen_parser::sccm::{SccmArtifact, SccmCoverageState, SccmRole, SccmRotation}; +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"; @@ -25,6 +30,38 @@ struct Record<'a> { 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 { @@ -95,6 +132,110 @@ fn keyed(update_id: &str, ci_id: &str, disposition: &str) -> String { ) } +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!( @@ -109,6 +250,10 @@ fn scan_failure_uses_sealed_exact_key_evidence_without_server_cause() { 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!( @@ -202,6 +347,31 @@ fn full_success_proves_all_eight_phases_without_cross_side_correlation() { 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] @@ -261,12 +431,57 @@ fn maintenance_window_defer_is_not_a_failure() { 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"; @@ -299,3 +514,155 @@ fn same_minute_updates_remain_separate_and_input_order_is_deterministic() { 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 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_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 09ed8797a..388590e91 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -7291,6 +7291,30 @@ fn expected_catalog_tuples() -> Vec { 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, From f5de9dc088934ece330eb28b4839464378cefc9e Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 01:24:56 -0400 Subject: [PATCH 356/422] fix(sccm): harden client updates evidence contract --- .../src/sccm/client/admission.rs | 67 ++++++ .../src/sccm/client/updates.rs | 202 +++++++++++++--- .../tests/sccm_client_updates.rs | 217 +++++++++++++++++- 3 files changed, 453 insertions(+), 33 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index 1dae2976e..8b5481f85 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -87,6 +87,8 @@ pub struct SccmClientAdmittedEvidence { evidence: Vec, source_coverage: BTreeMap, source_coverage_by_basename: BTreeMap, + source_basename_by_artifact: BTreeMap, + unavailable_source_basenames: BTreeSet, admitted_source_groups: BTreeSet, profiles_by_artifact: BTreeMap, integrity_seal: String, @@ -137,6 +139,26 @@ impl SccmClientAdmittedEvidence { Ok(self.source_coverage_by_basename.get(basename)) } + 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_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, @@ -184,6 +206,8 @@ impl SccmClientAdmittedEvidence { &self.evidence, &self.source_coverage, &self.source_coverage_by_basename, + &self.source_basename_by_artifact, + &self.unavailable_source_basenames, &self.admitted_source_groups, &self.profiles_by_artifact, )?; @@ -315,7 +339,36 @@ pub fn admit_client_evidence( }) .or_insert_with(|| fragment.coverage.clone()); } + for capture_gap in &canonical.capture_gaps { + source_coverage_by_basename + .entry(capture_gap.basename.clone()) + .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_basename_by_artifact = BTreeMap::new(); + let mut unavailable_source_basenames = canonical + .capture_gaps + .iter() + .filter(|gap| is_supported_raw_ccm_source(&gap.basename)) + .map(|gap| gap.basename.clone()) + .collect::>(); + unavailable_source_basenames.extend( + canonical + .groups + .iter() + .flat_map(|group| &group.fragments) + .filter(|fragment| { + is_supported_raw_ccm_fragment(fragment) && !is_bound_complete_capture(fragment) + }) + .map(|fragment| fragment.basename.clone()), + ); let mut unbound_complete_captures = BTreeSet::new(); for fragment in &canonical.physical_artifacts { let classified = classify_artifact_name(&fragment.basename, SccmRole::Client); @@ -325,15 +378,19 @@ pub fn admit_client_evidence( if fragment.coverage != SccmCoverageState::Captured || fragment.fragment_complete != Some(true) { + unavailable_source_basenames.insert(fragment.basename.clone()); 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(fragment.basename.clone()); continue; } if !has_supported_payload_encoding(fragment) { + unavailable_source_basenames.insert(fragment.basename.clone()); continue; } + source_basename_by_artifact.insert(fragment.artifact_id.clone(), fragment.basename.clone()); eligible.insert(fragment.artifact_id.clone(), (fragment, classified.family)); } let admitted_source_groups = canonical @@ -450,6 +507,8 @@ pub fn admit_client_evidence( &evidence, &source_coverage, &source_coverage_by_basename, + &source_basename_by_artifact, + &unavailable_source_basenames, &admitted_source_groups, &profiles_by_artifact, )?; @@ -457,6 +516,8 @@ pub fn admit_client_evidence( evidence, source_coverage, source_coverage_by_basename, + source_basename_by_artifact, + unavailable_source_basenames, admitted_source_groups, profiles_by_artifact, integrity_seal, @@ -649,6 +710,8 @@ struct IntegrityProjection<'a> { evidence: &'a [SccmEvidence], source_coverage: &'a BTreeMap, source_coverage_by_basename: &'a BTreeMap, + source_basename_by_artifact: &'a BTreeMap, + unavailable_source_basenames: &'a BTreeSet, admitted_source_groups: &'a BTreeSet, profile_assignments: &'a BTreeMap<&'a str, usize>, profiles: &'a [&'a SccmExtractionProfile], @@ -704,6 +767,8 @@ fn compute_integrity_seal( evidence: &[SccmEvidence], source_coverage: &BTreeMap, source_coverage_by_basename: &BTreeMap, + source_basename_by_artifact: &BTreeMap, + unavailable_source_basenames: &BTreeSet, admitted_source_groups: &BTreeSet, profiles_by_artifact: &BTreeMap, ) -> Result { @@ -736,6 +801,8 @@ fn compute_integrity_seal( evidence, source_coverage, source_coverage_by_basename, + source_basename_by_artifact, + unavailable_source_basenames, admitted_source_groups, profile_assignments: &profile_assignments, profiles: &unique_profiles, diff --git a/crates/cmtraceopen-parser/src/sccm/client/updates.rs b/crates/cmtraceopen-parser/src/sccm/client/updates.rs index 2bb047484..38ace0cce 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/updates.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/updates.rs @@ -8,6 +8,7 @@ use std::collections::BTreeMap; use chrono::{DateTime, SecondsFormat, Utc}; use serde::Serialize; +use sha2::{Digest, Sha256}; use crate::sccm::{ SccmConfidence, SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmFindingClass, @@ -114,7 +115,20 @@ pub struct SccmClientUpdateObservation { #[serde(rename_all = "camelCase")] pub struct SccmClientUpdateCoverage { pub logical_artifact_id: String, - pub state: SccmCoverageState, + 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)] @@ -209,17 +223,27 @@ struct UpdateFact { 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::<(String, String), Vec>::new(); + let mut facts_by_key = BTreeMap::>::new(); let mut observations = Vec::new(); for evidence in admitted.evidence()? { - if let Some(fact) = update_fact(evidence) { + if let Some(fact) = update_fact(admitted, evidence)? { facts_by_key - .entry((fact.key.update_id.clone(), fact.key.ci_id.clone())) + .entry(subject_key(&fact.key)) .or_default() .push(fact); } else if is_update_source(evidence.component.as_deref()) { @@ -277,11 +301,43 @@ pub fn analyze_client_updates( .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 fact in &facts { - effective_by_phase.insert(fact.phase, fact); + 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::>(); + let latest_timestamp = phase_facts + .iter() + .filter_map(|fact| fact.timestamp.utc_millis) + .max(); + let latest = phase_facts + .into_iter() + .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_by_phase.insert(phase, effective); } - let Some(last) = effective_by_phase.values().next_back().copied() else { + let Some(last) = effective_by_phase.values().next_back() else { continue; }; if let Some(counterpart) = facts.iter().find_map(counterpart_ready_fact) { @@ -289,17 +345,14 @@ pub fn analyze_client_updates( } let failed = effective_by_phase .values() - .copied() .filter(|fact| fact.disposition == PhaseDisposition::Failed) .min_by_key(|fact| fact.phase); let deferred = effective_by_phase .values() - .copied() .filter(|fact| fact.disposition == PhaseDisposition::Deferred) .max_by_key(|fact| fact.phase); let contradictory = effective_by_phase .values() - .copied() .filter(|fact| fact.disposition == PhaseDisposition::Contradictory) .min_by_key(|fact| fact.phase); let decisive = failed.or(deferred).or(contradictory).unwrap_or(last); @@ -335,7 +388,6 @@ pub fn analyze_client_updates( let decisive_is_success = failed.is_none() && deferred.is_none() && contradictory.is_none(); let last_successful_phase = effective_by_phase .values() - .copied() .filter(|fact| { fact.disposition == PhaseDisposition::Succeeded && (fact.phase < decisive.phase @@ -365,7 +417,11 @@ pub fn analyze_client_updates( .as_ref() .map(|request| vec![request.logical_artifact_id.clone()]) .unwrap_or_default(); - let transaction_id = format!("updates:update:{}", decisive.key.update_id); + 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(), @@ -386,8 +442,10 @@ pub fn analyze_client_updates( { findings.push(SccmClientUpdateFinding { finding_id: format!( - "finding:updates:{}:{}-{}", + "finding:updates:{}:{}:{}:{}-{}", decisive.key.update_id, + decisive.key.ci_id, + subject_discriminator, phase_name(output_phase), if failed.is_some() { "failure" @@ -540,9 +598,16 @@ fn update_coverage( } if declared { if let Some(state) = admitted.source_coverage(logical_artifact_id)? { + let all_complete = admitted + .require_captured_source(logical_artifact_id) + .is_ok(); coverage.push(SccmClientUpdateCoverage { logical_artifact_id: logical_artifact_id.to_owned(), - state: state.clone(), + state: if *state == SccmCoverageState::Captured && !all_complete { + SccmClientUpdateCoverageState::Partial + } else { + update_coverage_state(state) + }, }); } } @@ -593,9 +658,8 @@ fn declared_gap_phases( ("RebootCoordinator.log", SccmClientUpdatePhase::Reboot), ("StateMessage.log", SccmClientUpdatePhase::Report), ] { - if admitted - .source_coverage_for_basename(basename)? - .is_some_and(|coverage| *coverage != SccmCoverageState::Captured) + if admitted.source_coverage_for_basename(basename)?.is_some() + && !admitted.source_basename_is_complete(basename)? && !phases.contains(&phase) { phases.push(phase); @@ -604,19 +668,34 @@ fn declared_gap_phases( Ok(phases) } -fn update_fact(evidence: &SccmEvidence) -> Option { - let update_id = normalize_update_id(message_field(&evidence.message, "UpdateId")?)?; - let ci_id = safe_value(message_field(&evidence.message, "CIId")?)?; - let (phase, disposition) = phase_disposition(evidence)?; - Some(UpdateFact { +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 sealed_basename = admitted.source_basename_for_artifact(&evidence.reference.artifact_id)?; + 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"), + 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"), + sup_host_handle: optional_safe_handle(&evidence.message, "SupHostHandle", "safe:sup:"), confidence: SccmKeyConfidence::Low, extraction_profile_id: SCCM_EXPERIMENTAL_KEY_PROFILE_ID.to_owned(), }, @@ -624,11 +703,13 @@ fn update_fact(evidence: &SccmEvidence) -> Option { disposition, evidence: evidence.reference.clone(), timestamp: evidence.timestamp.clone(), - location_services_source: evidence - .component - .as_deref() - .is_some_and(|component| component.eq_ignore_ascii_case("LocationServices")), - }) + location_services_source: location_services_group_admitted + && sealed_basename == Some("LocationServices.log") + && evidence + .component + .as_deref() + .is_some_and(|component| component.eq_ignore_ascii_case("LocationServices")), + })) } fn counterpart_ready_fact(fact: &UpdateFact) -> Option { @@ -822,8 +903,65 @@ fn optional_safe_field(message: &str, label: &str) -> Option { safe_value(message_field(message, label)?) } -fn optional_safe_handle(message: &str, label: &str) -> Option { - safe_value(message_field(message, label)?).filter(|value| value.starts_with("safe:")) +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 { diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates.rs index a68bd37a6..038e4660f 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates.rs @@ -1,6 +1,7 @@ use cmtraceopen_parser::sccm::client::{ admit_client_evidence, analyze_client_updates, assess_client_intake, SccmClientCapturedPayload, - SccmClientIntakeArtifact, SccmClientIntakeBundle, SccmClientUpdatePhase, SccmClientUpdateState, + SccmClientIntakeArtifact, SccmClientIntakeBundle, SccmClientIntakeCaptureGap, + SccmClientUpdatePhase, SccmClientUpdateState, }; use cmtraceopen_parser::sccm::{ classify_artifact_name, SccmArtifact, SccmCoverageState, SccmRole, SccmRotation, @@ -589,6 +590,220 @@ fn later_same_phase_success_recovers_an_earlier_terminal_marker() { 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 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, bytes).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); +} + +#[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 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"), + }; + + for record in [spoofed_source, 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 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 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: "ScanAgent", + time: "13:00:01.000", + message: keyed(UPDATE_ID, "323011", "Scan 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()) From f7d60546ac5333ff71312e986bfbf4e48a2a721e Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 02:09:52 -0400 Subject: [PATCH 357/422] feat(sccm): analyze client inventory compliance and metering --- crates/cmtraceopen-parser/src/sccm/catalog.rs | 40 +- .../src/sccm/client/admission.rs | 108 ++- .../src/sccm/client/intake.rs | 21 + .../src/sccm/client/inventory.rs | 602 +++++++++------ .../cmtraceopen-parser/src/sccm/client/mod.rs | 5 +- .../cmtraceopen-parser/src/sccm/findings.rs | 8 + .../client/intake/access-denied/expected.json | 36 + .../sccm/client/intake/capped/expected.json | 36 + .../client/intake/collision/expected.json | 36 + .../current/DCMReporting.log | 1 + .../current/InventoryAgent.log | 1 + .../current/SWMTRReportGen.log | 1 + .../sccm/client/intake/complete/expected.json | 84 ++ .../sccm/client/intake/complete/manifest.json | 5 +- .../client/intake/missing-root/expected.json | 36 + .../client/intake/rotations/expected.json | 36 + .../tests/sccm_client_intake.rs | 6 +- .../tests/sccm_client_inventory.rs | 716 ++++++++++++++---- .../tests/sccm_spine_contract.rs | 16 - 19 files changed, 1376 insertions(+), 418 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-compliance/current/DCMReporting.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-inventory/current/InventoryAgent.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-metering/current/SWMTRReportGen.log diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs index ddf8faaa2..21a4f990c 100644 --- a/crates/cmtraceopen-parser/src/sccm/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -263,6 +263,34 @@ const CLIENT_SOURCE_MEMBERSHIPS: &[SccmClientSourceMembership] = &[ basename: "ReportingEvents.log", logical_artifact_ids: &["client-windows-update-supplemental"], }, + 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] { @@ -492,12 +520,6 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ role: SccmRole::Client, family: SccmArtifactFamily::ClientInventory, }, - CatalogSpec { - basename: "CIAgent", - logical_name: "ciAgent", - role: SccmRole::Client, - family: SccmArtifactFamily::ClientCompliance, - }, CatalogSpec { basename: "CITaskMgr", logical_name: "ciTaskMgr", @@ -516,12 +538,6 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ role: SccmRole::Client, family: SccmArtifactFamily::ClientCompliance, }, - CatalogSpec { - basename: "StateMessage", - logical_name: "stateMessage", - role: SccmRole::Client, - family: SccmArtifactFamily::ClientCompliance, - }, CatalogSpec { basename: "SWMTRReportGen", logical_name: "swmtrReportGen", diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index 8b5481f85..a00922411 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -25,7 +25,7 @@ use crate::sccm::catalog::classify_artifact_name; use crate::sccm::evidence::SccmRawEvidenceSnapshot; use crate::sccm::{ extract_keys, SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmEvidence, - SccmExtractionProfile, SccmKeyExtractionResult, SccmRole, SccmTimeOrderingState, + SccmExtractionProfile, SccmKeyExtractionResult, SccmRole, SccmRotation, SccmTimeOrderingState, }; use super::{ @@ -88,12 +88,21 @@ pub struct SccmClientAdmittedEvidence { 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, 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, +} + /// 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. @@ -150,6 +159,14 @@ impl SccmClientAdmittedEvidence { .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, @@ -204,12 +221,15 @@ impl SccmClientAdmittedEvidence { pub(crate) fn verify_integrity(&self) -> Result<(), SccmClientEvidenceAdmissionError> { let recomputed = compute_integrity_seal( &self.evidence, - &self.source_coverage, - &self.source_coverage_by_basename, - &self.source_basename_by_artifact, - &self.unavailable_source_basenames, - &self.admitted_source_groups, - &self.profiles_by_artifact, + 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, + }, )?; (recomputed == self.integrity_seal) .then_some(()) @@ -352,6 +372,20 @@ pub fn admit_client_evidence( .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, + }); + } let mut source_basename_by_artifact = BTreeMap::new(); let mut unavailable_source_basenames = canonical .capture_gaps @@ -505,18 +539,22 @@ pub fn admit_client_evidence( evidence.sort_by(compare_evidence); let integrity_seal = compute_integrity_seal( &evidence, - &source_coverage, - &source_coverage_by_basename, - &source_basename_by_artifact, - &unavailable_source_basenames, - &admitted_source_groups, - &profiles_by_artifact, + 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, + }, )?; Ok(SccmClientAdmittedEvidence { evidence, source_coverage, source_coverage_by_basename, source_basename_by_artifact, + source_artifacts, unavailable_source_basenames, admitted_source_groups, profiles_by_artifact, @@ -711,6 +749,7 @@ struct IntegrityProjection<'a> { 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>, @@ -763,14 +802,19 @@ impl Write for BoundedIntegrityWriter { } } +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, +} + fn compute_integrity_seal( evidence: &[SccmEvidence], - source_coverage: &BTreeMap, - source_coverage_by_basename: &BTreeMap, - source_basename_by_artifact: &BTreeMap, - unavailable_source_basenames: &BTreeSet, - admitted_source_groups: &BTreeSet, - profiles_by_artifact: &BTreeMap, + authority: IntegrityAuthority<'_>, ) -> Result { // Many rotations legitimately select the same profile. Seal the complete // profile once and bind each artifact to its deterministic index so the @@ -779,7 +823,7 @@ fn compute_integrity_seal( let mut profile_indices = BTreeMap::::new(); let mut unique_profiles = Vec::new(); let mut profile_assignments = BTreeMap::new(); - for (artifact_id, profile) in profiles_by_artifact { + 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) { @@ -794,16 +838,30 @@ fn compute_integrity_seal( 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_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, - source_coverage_by_basename, - source_basename_by_artifact, - unavailable_source_basenames, - admitted_source_groups, + 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, }, diff --git a/crates/cmtraceopen-parser/src/sccm/client/intake.rs b/crates/cmtraceopen-parser/src/sccm/client/intake.rs index f12263fb4..784f7f731 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -143,6 +143,9 @@ pub enum SccmClientWorkflow { Policy, Deployment, Updates, + Inventory, + Compliance, + Metering, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -875,6 +878,9 @@ const DEPLOYMENT: &[SccmClientWorkflow] = &[SccmClientWorkflow::Deployment]; 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]; @@ -894,6 +900,11 @@ const CLIENT_SOURCE_GROUPS: &[ClientSourceGroupSpec] = &[ 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, @@ -909,6 +920,11 @@ const CLIENT_SOURCE_GROUPS: &[ClientSourceGroupSpec] = &[ 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, @@ -924,6 +940,11 @@ const CLIENT_SOURCE_GROUPS: &[ClientSourceGroupSpec] = &[ 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, diff --git a/crates/cmtraceopen-parser/src/sccm/client/inventory.rs b/crates/cmtraceopen-parser/src/sccm/client/inventory.rs index 360231a3c..275f26bf7 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/inventory.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/inventory.rs @@ -1,18 +1,22 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::models::log_entry::Severity; use super::super::{ - classify_artifact_name, normalize_key, SccmArtifact, SccmArtifactFamily, SccmCorrelationKey, - SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, SccmEvidenceRef, - SccmTimeOrderingState, + normalize_key, SccmCorrelationKey, SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, + SccmEvidenceRef, SccmFindingClass, SccmKeyConfidence, SccmRole, SccmTimeOrderingState, + SCCM_EXPERIMENTAL_KEY_PROFILE_ID, }; +use super::{SccmClientAdmittedEvidence, SccmClientEvidenceAdmissionError}; -const OBSERVED_VERSION_PREFIX: &str = "5.00.TEST."; +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 SccmWorkflow { +pub enum SccmClientExtendedWorkflow { Inventory, Compliance, Metering, @@ -20,7 +24,7 @@ pub enum SccmWorkflow { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub enum SccmPhase { +pub enum SccmClientExtendedPhase { Collect, Provider, Serialize, @@ -33,26 +37,27 @@ pub enum SccmPhase { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub enum SccmTransactionState { +pub enum SccmClientExtendedState { InProgress, Succeeded, Failed, Recovered, Contradictory, EvaluatedNonCompliant, + Remediated, + BlockedOrDeferred, InsufficientEvidence, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct SccmTransaction { +pub struct SccmClientExtendedTransaction { pub transaction_id: String, - pub workflow: SccmWorkflow, + pub workflow: SccmClientExtendedWorkflow, pub profile_id: String, - pub configmgr_version: String, - pub phase: SccmPhase, - pub state: SccmTransactionState, - pub last_successful_phase: Option, + pub phase: SccmClientExtendedPhase, + pub state: SccmClientExtendedState, + pub last_successful_phase: Option, pub keys: Vec, pub evidence: Vec, pub coverage_gap_artifact_ids: Vec, @@ -60,48 +65,66 @@ pub struct SccmTransaction { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct SccmCoverageGap { - pub workflow: SccmWorkflow, - pub artifact_id: String, - pub source_basename: String, +pub struct SccmClientExtendedCoverageGap { + pub workflow: SccmClientExtendedWorkflow, + pub logical_artifact_id: String, pub state: SccmCoverageState, pub reason: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct SccmSourceObservation { - pub workflow: SccmWorkflow, - pub artifact_id: String, - pub evidence_id: String, +pub struct SccmClientExtendedObservation { + pub workflow: SccmClientExtendedWorkflow, pub reason: String, + pub artifact_ids: Vec, + pub evidence: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[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_id: Option, + pub evidence: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct SccmClientAnalysis { - pub transactions: Vec, - pub coverage: Vec, - pub source_local_observations: Vec, +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: SccmWorkflow, + workflow: SccmClientExtendedWorkflow, tuple: String, } #[derive(Debug, Clone)] struct Fact { - workflow: SccmWorkflow, - phase: SccmPhase, + workflow: SccmClientExtendedWorkflow, + phase: SccmClientExtendedPhase, disposition: Disposition, terminal: bool, evidence: SccmEvidence, keys: Vec, tuple: String, profile_id: String, - configmgr_version: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -113,164 +136,207 @@ enum Disposition { Other, } -/// Analyze only the three client-extended workflows represented by the observed -/// #325 source catalog. The function accepts normalized evidence and metadata; -/// it never reads files, infers paths, or reparses CCM records. +/// Analyze inventory, compliance, and metering only through sealed client +/// evidence. The reducer never accepts caller-assembled artifacts or records. pub fn analyze_client_extended( - artifacts: &[SccmArtifact], - evidence: &[SccmEvidence], -) -> SccmClientAnalysis { - let mut coverage = Vec::new(); + admitted: &SccmClientAdmittedEvidence, +) -> Result { + let coverage = extended_coverage(admitted)?; let mut observations = Vec::new(); let mut facts = BTreeMap::>::new(); - for artifact in artifacts { - let Some(context) = artifact_context(artifact) else { + for (artifact_id, source) in admitted.source_artifacts()? { + let Some(workflow) = workflow_for_basename(&source.basename) else { continue; }; - - if artifact.coverage != SccmCoverageState::Captured { - coverage.push(SccmCoverageGap { - workflow: context.workflow, - artifact_id: artifact.artifact_id.clone(), - source_basename: context.source_basename.to_owned(), - state: artifact.coverage.clone(), - reason: "The admitted source was not captured; this is a coverage gap, not a workflow outcome.".to_owned(), - }); + 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(), + artifact_ids: vec![artifact_id.clone()], + evidence: Vec::new(), + }); + } - let Some(version) = artifact.configmgr_version.as_deref() else { - coverage.push(version_gap(context, artifact)); + for evidence in admitted.evidence()? { + let Some(context) = evidence_context(admitted, evidence)? else { continue; }; - if !version.starts_with(OBSERVED_VERSION_PREFIX) { - coverage.push(version_gap(context, artifact)); - continue; - } - let profile_id = profile_id(context.workflow); - for item in evidence - .iter() - .filter(|item| item.reference.artifact_id == artifact.artifact_id) - { - let Some(fact) = parse_fact( - context, - profile_id, - version, - artifact, - item, - &mut observations, - ) else { - continue; - }; - facts - .entry(TransactionKey { - workflow: fact.workflow, - tuple: fact.tuple.clone(), - }) - .or_default() - .push(fact); - } + 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); } - coverage.sort_by(|left, right| { - ( - left.workflow, - &left.artifact_id, - &left.source_basename, - &left.reason, - ) - .cmp(&( - right.workflow, - &right.artifact_id, - &right.source_basename, - &right.reason, - )) - }); observations.sort_by(|left, right| { - ( - left.workflow, - &left.artifact_id, - &left.evidence_id, - &left.reason, - ) - .cmp(&( - right.workflow, - &right.artifact_id, - &right.evidence_id, - &right.reason, - )) + left.workflow + .cmp(&right.workflow) + .then_with(|| left.artifact_ids.cmp(&right.artifact_ids)) + .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 transactions = facts + let mut transactions = facts .into_values() .map(|mut group| reduce_group(&mut group)) - .collect(); + .collect::>(); + for transaction in &mut transactions { + transaction.coverage_gap_artifact_ids = coverage + .iter() + .filter(|gap| { + gap.workflow == transaction.workflow && gap.state != SccmCoverageState::Captured + }) + .map(|gap| gap.logical_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::>(); - SccmClientAnalysis { + 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, Copy)] struct ArtifactContext { - workflow: SccmWorkflow, + workflow: SccmClientExtendedWorkflow, source_basename: &'static str, } -fn artifact_context(artifact: &SccmArtifact) -> Option { - let catalog = classify_artifact_name(&artifact.display_name, artifact.role.clone()); - let (workflow, source_basename) = match &catalog.family { - SccmArtifactFamily::ClientInventory => (SccmWorkflow::Inventory, catalog.logical_name), - SccmArtifactFamily::ClientCompliance => (SccmWorkflow::Compliance, catalog.logical_name), - SccmArtifactFamily::ClientMetering => (SccmWorkflow::Metering, catalog.logical_name), - _ => return None, - }; +fn extended_coverage( + admitted: &SccmClientAdmittedEvidence, +) -> Result, SccmClientEvidenceAdmissionError> { + let mut coverage = Vec::new(); + for (workflow, logical_artifact_id) in [ + (SccmClientExtendedWorkflow::Inventory, "client-inventory"), + (SccmClientExtendedWorkflow::Compliance, "client-compliance"), + ( + SccmClientExtendedWorkflow::Compliance, + "client-policy-state", + ), + (SccmClientExtendedWorkflow::Metering, "client-metering"), + ] { + let state = admitted + .source_coverage(logical_artifact_id)? + .cloned() + .unwrap_or(SccmCoverageState::Absent); + coverage.push(SccmClientExtendedCoverageGap { + workflow, + logical_artifact_id: logical_artifact_id.to_owned(), + reason: if state == SccmCoverageState::Captured { + "The bounded workflow source group was captured; transaction claims still require exact record evidence." + } else { + "The bounded workflow source group is incomplete; coverage cannot become a workflow outcome." + } + .to_owned(), + state, + }); + } + Ok(coverage) +} - let source_basename = match workflow { - SccmWorkflow::Inventory => match source_basename.as_str() { - "inventoryAgent" => "InventoryAgent.log", - "inventoryProvider" => "InventoryProvider.log", - "inventoryAgentProvider" => "InventoryAgentProvider.log", - _ => return None, - }, - SccmWorkflow::Compliance => match source_basename.as_str() { - "ciAgent" => "CIAgent.log", - "ciTaskMgr" => "CITaskMgr.log", - "dcmAgent" => "DCMAgent.log", - "dcmReporting" => "DCMReporting.log", - "stateMessage" => "StateMessage.log", - _ => return None, - }, - SccmWorkflow::Metering => "SWMTRReportGen.log", +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), }; + if admitted.source_basename_for_artifact(&evidence.reference.artifact_id)? + != Some(source_basename) + { + return Ok(None); + } - Some(ArtifactContext { + Ok(Some(ArtifactContext { workflow, source_basename, - }) + })) } -fn version_gap(context: ArtifactContext, artifact: &SccmArtifact) -> SccmCoverageGap { - SccmCoverageGap { - workflow: context.workflow, - artifact_id: artifact.artifact_id.clone(), - source_basename: context.source_basename.to_owned(), - state: artifact.coverage.clone(), - reason: "The source version is absent or outside the observed profile; no state claim was promoted.".to_owned(), +fn workflow_for_basename(basename: &str) -> Option { + match basename { + "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, - version: &str, - artifact: &SccmArtifact, evidence: &SccmEvidence, - observations: &mut Vec, + observations: &mut Vec, ) -> Option { let phase = field(&evidence.message, "Phase").and_then(|value| parse_phase(context.workflow, &value)); @@ -282,7 +348,6 @@ fn parse_fact( observe( observations, context.workflow, - artifact, evidence, "The record has no admitted phase for this workflow.", ); @@ -292,7 +357,6 @@ fn parse_fact( observe( observations, context.workflow, - artifact, evidence, "The source family cannot establish this phase.", ); @@ -304,7 +368,6 @@ fn parse_fact( observe( observations, context.workflow, - artifact, evidence, "The record explicitly names a different workflow family.", ); @@ -320,7 +383,6 @@ fn parse_fact( observe( observations, context.workflow, - artifact, evidence, "The record lacks the complete exact workflow key tuple.", ); @@ -330,13 +392,12 @@ fn parse_fact( observe( observations, context.workflow, - artifact, evidence, "The workflow key tuple contains an invalid or unbounded value.", ); return None; }; - if context.workflow == SccmWorkflow::Compliance + if context.workflow == SccmClientExtendedWorkflow::Compliance && disposition == Disposition::NonCompliant && !field(&evidence.message, "ResultType") .is_some_and(|value| value.eq_ignore_ascii_case("Evaluation")) @@ -344,7 +405,6 @@ fn parse_fact( observe( observations, context.workflow, - artifact, evidence, "A noncompliant result is promotable only from an explicit evaluation record.", ); @@ -361,37 +421,42 @@ fn parse_fact( keys, tuple, profile_id: profile_id.to_owned(), - configmgr_version: version.to_owned(), }) } -fn required_fields(workflow: SccmWorkflow) -> &'static [&'static str] { +fn required_fields(workflow: SccmClientExtendedWorkflow) -> &'static [&'static str] { match workflow { - SccmWorkflow::Inventory => &["InventoryCycleId", "ResourceHandle", "ReportId"], - SccmWorkflow::Compliance => &["CiId", "BaselineId", "StateId", "ResourceHandle"], - SccmWorkflow::Metering => &["MeteringCycleId", "RuleId", "ReportId", "ResourceHandle"], + SccmClientExtendedWorkflow::Inventory => { + &["InventoryCycleId", "ResourceHandle", "ReportId"] + } + SccmClientExtendedWorkflow::Compliance => { + &["CiId", "BaselineId", "StateId", "ResourceHandle"] + } + SccmClientExtendedWorkflow::Metering => { + &["MeteringCycleId", "RuleId", "ReportId", "ResourceHandle"] + } } } fn make_keys( - workflow: SccmWorkflow, + workflow: SccmClientExtendedWorkflow, values: &[String], profile_id: &str, evidence: &SccmEvidence, ) -> Option> { let kinds = match workflow { - SccmWorkflow::Inventory => vec![ + SccmClientExtendedWorkflow::Inventory => vec![ SccmCorrelationKeyKind::InventoryCycleId, SccmCorrelationKeyKind::ResourceHandle, SccmCorrelationKeyKind::ReportId, ], - SccmWorkflow::Compliance => vec![ + SccmClientExtendedWorkflow::Compliance => vec![ SccmCorrelationKeyKind::ComplianceCiId, SccmCorrelationKeyKind::BaselineId, SccmCorrelationKeyKind::ComplianceStateId, SccmCorrelationKeyKind::ResourceHandle, ], - SccmWorkflow::Metering => vec![ + SccmClientExtendedWorkflow::Metering => vec![ SccmCorrelationKeyKind::MeteringCycleId, SccmCorrelationKeyKind::RuleId, SccmCorrelationKeyKind::ReportId, @@ -404,6 +469,7 @@ fn make_keys( .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 @@ -412,7 +478,7 @@ fn make_keys( .collect() } -fn reduce_group(group: &mut [Fact]) -> SccmTransaction { +fn reduce_group(group: &mut [Fact]) -> SccmClientExtendedTransaction { group.sort_by(|left, right| { ( left.evidence.timestamp.utc_millis, @@ -443,6 +509,10 @@ fn reduce_group(group: &mut [Fact]) -> SccmTransaction { .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 == Disposition::Succeeded && field(&fact.evidence.message, "ResultType") @@ -450,30 +520,40 @@ fn reduce_group(group: &mut [Fact]) -> SccmTransaction { && field(&fact.evidence.message, "Disposition") .is_some_and(|value| value.eq_ignore_ascii_case("Compliant")) }); + 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) { - SccmTransactionState::Recovered + SccmClientExtendedState::Recovered } else { - SccmTransactionState::Contradictory + SccmClientExtendedState::Contradictory } } else if !failures.is_empty() { - SccmTransactionState::Failed + SccmClientExtendedState::Failed } else if !noncompliant.is_empty() && compliant { - SccmTransactionState::Contradictory + SccmClientExtendedState::Contradictory } else if !noncompliant.is_empty() { - SccmTransactionState::EvaluatedNonCompliant + SccmClientExtendedState::EvaluatedNonCompliant + } else if !deferred.is_empty() { + SccmClientExtendedState::BlockedOrDeferred + } else if remediated { + SccmClientExtendedState::Remediated } else if !successes.is_empty() { - SccmTransactionState::Succeeded + SccmClientExtendedState::Succeeded } else { - SccmTransactionState::InProgress + SccmClientExtendedState::InProgress }; let last_successful_phase = group .iter() .filter(|fact| { fact.disposition == Disposition::Succeeded - || (fact.workflow == SccmWorkflow::Compliance + || (fact.workflow == SccmClientExtendedWorkflow::Compliance && fact.disposition == Disposition::NonCompliant) }) .map(|fact| fact.phase) @@ -495,15 +575,14 @@ fn reduce_group(group: &mut [Fact]) -> SccmTransaction { .max() .unwrap_or(first.phase); let transaction_id = format!( - "{}:{}", + "client-extended:{}:{}", workflow_name(first.workflow), - first.tuple.to_ascii_lowercase() + tuple_discriminator(first.workflow, &first.tuple) ); - SccmTransaction { + SccmClientExtendedTransaction { transaction_id, workflow: first.workflow, profile_id: first.profile_id.clone(), - configmgr_version: first.configmgr_version.clone(), phase, state, last_successful_phase, @@ -513,6 +592,78 @@ fn reduce_group(group: &mut [Fact]) -> SccmTransaction { } } +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::Remediated + | SccmClientExtendedState::Recovered => return None, + }; + let next_artifact_id = transaction + .coverage_gap_artifact_ids + .first() + .cloned() + .or_else(|| { + matches!( + transaction.state, + SccmClientExtendedState::BlockedOrDeferred + | SccmClientExtendedState::Contradictory + | SccmClientExtendedState::InsufficientEvidence + ) + .then(|| workflow_artifact_id(transaction.workflow).to_owned()) + }); + 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::Remediated + | SccmClientExtendedState::Recovered => Severity::Info, + }, + state: transaction.state, + phase: transaction.phase, + confidence: SccmKeyConfidence::Low, + keys: transaction.keys.clone(), + next_artifact_id, + 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()[..8] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +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; @@ -528,55 +679,77 @@ fn ordered_recovery(failures: &[&Fact], successes: &[&Fact]) -> bool { && failure.evidence.timestamp.utc_millis < success.evidence.timestamp.utc_millis } -fn parse_phase(workflow: SccmWorkflow, value: &str) -> Option { +fn parse_phase( + workflow: SccmClientExtendedWorkflow, + value: &str, +) -> Option { let phase = match value.to_ascii_lowercase().as_str() { - "collect" => SccmPhase::Collect, - "provider" => SccmPhase::Provider, - "serialize" => SccmPhase::Serialize, - "queue" => SccmPhase::Queue, - "evaluate" => SccmPhase::Evaluate, - "remediate" => SccmPhase::Remediate, - "aggregate" => SccmPhase::Aggregate, - "report" => SccmPhase::Report, + "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 { - SccmWorkflow::Inventory => matches!( + SccmClientExtendedWorkflow::Inventory => matches!( phase, - SccmPhase::Collect - | SccmPhase::Provider - | SccmPhase::Serialize - | SccmPhase::Queue - | SccmPhase::Report + SccmClientExtendedPhase::Collect + | SccmClientExtendedPhase::Provider + | SccmClientExtendedPhase::Serialize + | SccmClientExtendedPhase::Queue + | SccmClientExtendedPhase::Report ), - SccmWorkflow::Compliance => { + SccmClientExtendedWorkflow::Compliance => { matches!( phase, - SccmPhase::Evaluate | SccmPhase::Remediate | SccmPhase::Report + SccmClientExtendedPhase::Evaluate + | SccmClientExtendedPhase::Remediate + | SccmClientExtendedPhase::Report ) } - SccmWorkflow::Metering => { + SccmClientExtendedWorkflow::Metering => { matches!( phase, - SccmPhase::Collect | SccmPhase::Aggregate | SccmPhase::Report + SccmClientExtendedPhase::Collect + | SccmClientExtendedPhase::Aggregate + | SccmClientExtendedPhase::Report ) } }; valid.then_some(phase) } -fn source_allows_phase(source: &str, phase: SccmPhase) -> bool { +fn source_allows_phase(source: &str, phase: SccmClientExtendedPhase) -> bool { match source { - "InventoryAgent.log" => phase == SccmPhase::Collect, - "InventoryProvider.log" => matches!(phase, SccmPhase::Provider | SccmPhase::Serialize), - "InventoryAgentProvider.log" => matches!(phase, SccmPhase::Queue | SccmPhase::Report), - "CIAgent.log" => phase == SccmPhase::Evaluate, - "CITaskMgr.log" => matches!(phase, SccmPhase::Evaluate | SccmPhase::Remediate), - "DCMAgent.log" => phase == SccmPhase::Remediate, - "DCMReporting.log" | "StateMessage.log" => phase == SccmPhase::Report, + "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, - SccmPhase::Collect | SccmPhase::Aggregate | SccmPhase::Report + SccmClientExtendedPhase::Collect + | SccmClientExtendedPhase::Aggregate + | SccmClientExtendedPhase::Report ), _ => false, } @@ -599,33 +772,28 @@ fn field(message: &str, label: &str) -> Option { }) } -fn profile_id(workflow: SccmWorkflow) -> &'static str { - match workflow { - SccmWorkflow::Inventory => "sccm-client-inventory-5.00.test-v1", - SccmWorkflow::Compliance => "sccm-client-compliance-5.00.test-v1", - SccmWorkflow::Metering => "sccm-client-metering-5.00.test-v1", - } +fn profile_id(_workflow: SccmClientExtendedWorkflow) -> &'static str { + SCCM_EXPERIMENTAL_KEY_PROFILE_ID } -fn workflow_name(workflow: SccmWorkflow) -> &'static str { +fn workflow_name(workflow: SccmClientExtendedWorkflow) -> &'static str { match workflow { - SccmWorkflow::Inventory => "inventory", - SccmWorkflow::Compliance => "compliance", - SccmWorkflow::Metering => "metering", + SccmClientExtendedWorkflow::Inventory => "inventory", + SccmClientExtendedWorkflow::Compliance => "compliance", + SccmClientExtendedWorkflow::Metering => "metering", } } fn observe( - observations: &mut Vec, - workflow: SccmWorkflow, - artifact: &SccmArtifact, + observations: &mut Vec, + workflow: SccmClientExtendedWorkflow, evidence: &SccmEvidence, reason: &str, ) { - observations.push(SccmSourceObservation { + observations.push(SccmClientExtendedObservation { workflow, - artifact_id: artifact.artifact_id.clone(), - evidence_id: evidence.evidence_id.clone(), reason: reason.to_owned(), + artifact_ids: vec![evidence.reference.artifact_id.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 index fc6e23daf..73c5023f9 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -19,7 +19,8 @@ pub use admission::{ }; pub use intake::*; pub use inventory::{ - analyze_client_extended, SccmClientAnalysis, SccmCoverageGap, SccmPhase, SccmSourceObservation, - SccmTransaction, SccmTransactionState, SccmWorkflow, + analyze_client_extended, SccmClientExtendedAnalysis, SccmClientExtendedCoverageGap, + SccmClientExtendedFinding, SccmClientExtendedObservation, SccmClientExtendedPhase, + SccmClientExtendedState, SccmClientExtendedTransaction, SccmClientExtendedWorkflow, }; pub use updates::*; diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 368a972c8..cc7748172 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -2694,6 +2694,14 @@ fn correlation_key_kind_order(kind: &SccmCorrelationKeyKind) -> u8 { SccmCorrelationKeyKind::RequestId => 11, SccmCorrelationKeyKind::TopicId => 12, SccmCorrelationKeyKind::StateMessageId => 13, + SccmCorrelationKeyKind::InventoryCycleId => 14, + SccmCorrelationKeyKind::ReportId => 15, + SccmCorrelationKeyKind::ResourceHandle => 16, + SccmCorrelationKeyKind::ComplianceCiId => 17, + SccmCorrelationKeyKind::BaselineId => 18, + SccmCorrelationKeyKind::ComplianceStateId => 19, + SccmCorrelationKeyKind::MeteringCycleId => 20, + SccmCorrelationKeyKind::RuleId => 21, } } 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 index b220e2ad9..7947ad36b 100644 --- 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 @@ -19,6 +19,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-compliance", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-content", "coverage": "absent", @@ -34,6 +39,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-inventory", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-location", "coverage": "absent", @@ -49,6 +59,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-metering", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-policy-agent", "coverage": "accessDenied", @@ -135,6 +150,13 @@ "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, @@ -156,6 +178,13 @@ "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, @@ -177,6 +206,13 @@ "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", diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json index 7ac048b2e..d50d6aa8f 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json @@ -19,6 +19,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-compliance", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-content", "coverage": "capped", @@ -36,6 +41,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-inventory", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-location", "coverage": "absent", @@ -51,6 +61,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-metering", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-policy-agent", "coverage": "absent", @@ -119,6 +134,13 @@ "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": "fixture-capped-content-root-a-current", @@ -140,6 +162,13 @@ "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, @@ -161,6 +190,13 @@ "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, 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 index 8a0597efd..6de58d75c 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/expected.json @@ -22,6 +22,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-compliance", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-content", "coverage": "absent", @@ -37,6 +42,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-inventory", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-location", "coverage": "absent", @@ -52,6 +62,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-metering", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-policy-agent", "coverage": "absent", @@ -128,6 +143,13 @@ "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, @@ -149,6 +171,13 @@ "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, @@ -170,6 +199,13 @@ "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, 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-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-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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json index 91196012c..44c38b57f 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json @@ -25,6 +25,13 @@ "fixture-complete-ccmsetup-root-a-current" ] }, + { + "logicalArtifactId": "client-compliance", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-report-root-a-numbered-04" + ] + }, { "logicalArtifactId": "client-content", "coverage": "captured", @@ -47,6 +54,13 @@ "fixture-complete-identity-root-a-current" ] }, + { + "logicalArtifactId": "client-inventory", + "coverage": "captured", + "fragmentArtifactIds": [ + "fixture-complete-report-root-a-numbered-03" + ] + }, { "logicalArtifactId": "client-location", "coverage": "captured", @@ -68,6 +82,13 @@ "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", @@ -231,6 +252,48 @@ "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", @@ -300,6 +363,9 @@ "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" ], @@ -362,6 +428,24 @@ "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, 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 index 70d983aac..1e0d02906 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/manifest.json @@ -22,6 +22,9 @@ {"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-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 index 05cfd4a4a..c0c70c2a9 100644 --- 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 @@ -25,6 +25,11 @@ "fixture-missing-ccmsetup-current" ] }, + { + "logicalArtifactId": "client-compliance", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-content", "coverage": "absent", @@ -47,6 +52,11 @@ "fixture-missing-identity-current" ] }, + { + "logicalArtifactId": "client-inventory", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-location", "coverage": "absent", @@ -66,6 +76,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-metering", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-policy-agent", "coverage": "absent", @@ -280,6 +295,13 @@ "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", @@ -308,6 +330,13 @@ "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", @@ -329,6 +358,13 @@ "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", 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 index fccea92a0..501475dfd 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json @@ -23,6 +23,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-compliance", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-content", "coverage": "absent", @@ -38,6 +43,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-inventory", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-location", "coverage": "absent", @@ -53,6 +63,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-metering", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-policy-agent", "coverage": "absent", @@ -148,6 +163,13 @@ "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, @@ -169,6 +191,13 @@ "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, @@ -190,6 +219,13 @@ "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, diff --git a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs index 091beafa9..6d8945337 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs @@ -1484,7 +1484,7 @@ 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(), 14); + assert_eq!(declared.len(), 17); assert_eq!(intake.groups.len(), declared.len()); assert!(intake .groups @@ -1663,8 +1663,8 @@ fn missing_access_denied_and_capped_sources_remain_exact_coverage_states() { .all(|group| group.coverage == SccmCoverageState::Absent)); assert_eq!( missing.coverage_gaps.len(), - 15, - "the shared LocationServices declaration contributes one gap to each consumer group, while maintenance and reboot remain explicit" + 18, + "the shared LocationServices declaration contributes one gap to each consumer group, while maintenance, reboot, and extended workflow groups remain explicit" ); assert_eq!( missing diff --git a/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs b/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs index b7634c8e4..d8cff88e6 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs @@ -1,89 +1,298 @@ +use cmtraceopen_parser::models::log_entry::Severity; +use cmtraceopen_parser::sccm::client::{ + admit_client_evidence, analyze_client_extended, assess_client_intake, + SccmClientCapturedPayload, SccmClientExtendedState, SccmClientExtendedWorkflow, + SccmClientIntakeArtifact, SccmClientIntakeBundle, +}; use cmtraceopen_parser::sccm::{ - analyze_client_extended, SccmArtifact, SccmCoverageState, SccmEvidence, SccmEvidenceRef, - SccmRole, SccmRotation, SccmTimeOrderingState, SccmTimestamp, SccmTransactionState, - SccmWorkflow, + SccmArtifact, SccmCoverageState, SccmFindingClass, SccmKeyConfidence, SccmRole, SccmRotation, +}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + path::{Path, PathBuf}, }; -fn artifact(id: &str, name: &str, coverage: SccmCoverageState) -> SccmArtifact { - SccmArtifact { - artifact_id: id.to_owned(), - display_name: name.to_owned(), - original_path: None, - host: Some("synthetic-host".to_owned()), - role: SccmRole::Client, - configmgr_version: Some("5.00.TEST.1".to_owned()), - collected_at_utc: Some("2026-07-30T00:00:00Z".to_owned()), - rotation: SccmRotation::Current, - coverage, - encoding: Some("utf-8".to_owned()), +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 evidence(artifact_id: &str, id: &str, millis: i64, message: &str) -> SccmEvidence { - SccmEvidence { - evidence_id: id.to_owned(), - reference: SccmEvidenceRef { - artifact_id: artifact_id.to_owned(), - entry_id: id.to_owned(), - line_start: Some(1), - line_end: Some(1), - }, - role: SccmRole::Client, - component: None, - ccm_source_file: None, - message: message.to_owned(), - timestamp: SccmTimestamp { - original_display: Some("00:00:00.000+000".to_owned()), - offset_minutes: Some(0), - utc_millis: Some(millis), - ordering_state: SccmTimeOrderingState::NormalizedUtc, - }, - execution_context: None, +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 +} + +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(); + for (index, source) in manifest["artifacts"] + .as_array() + .ok_or("manifest artifacts missing")? + .iter() + .enumerate() + { + let mut 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(); + // The preparation corpus predates sealed admission. Bridge its reviewed + // synthetic version to the current experimental profile and keep the + // explicitly unknown/invalid-time controls as non-admitted coverage. + if coverage == SccmCoverageState::Captured + && (source_version == Some("9.99.UNKNOWN") + || preparation_artifact_id.ends_with("-invalid-offset")) + { + coverage = SccmCoverageState::ParseFailed; + } + let artifact_id = format!("fixture-update-numbered-{:02}", index + 1); + 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(|_| "5.00.9128.1000".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())?; + admit_client_evidence(&bundle, &assessment, &payloads).map_err(|error| error.to_string()) } #[test] fn separates_inventory_compliance_and_metering_transactions() { - let artifacts = [ - artifact( - "inventory", - "InventoryAgentProvider.log", - SccmCoverageState::Captured, - ), - artifact( - "compliance", - "DCMReporting.log", - SccmCoverageState::Captured, - ), - artifact( - "metering", - "SWMTRReportGen.log", - SccmCoverageState::Captured, - ), - ]; - let evidence = vec![ - evidence( - "inventory", - "inventory-report", - 1_000, - "Family=inventory InventoryCycleId=INV-CYCLE-001 ResourceHandle=safe:resource:inventory-001 ReportId=INV-REPORT-001 Phase=Report Disposition=Succeeded Terminal=true", - ), - evidence( - "compliance", - "compliance-report", - 1_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", - ), - evidence( - "metering", - "metering-report", - 1_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(&artifacts, &evidence); + 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 @@ -92,15 +301,15 @@ fn separates_inventory_compliance_and_metering_transactions() { .map(|transaction| transaction.workflow) .collect::>(), vec![ - SccmWorkflow::Inventory, - SccmWorkflow::Compliance, - SccmWorkflow::Metering + SccmClientExtendedWorkflow::Inventory, + SccmClientExtendedWorkflow::Compliance, + SccmClientExtendedWorkflow::Metering, ] ); assert!(result .transactions .iter() - .all(|transaction| transaction.state == SccmTransactionState::Succeeded)); + .all(|transaction| transaction.state == SccmClientExtendedState::Succeeded)); assert!(result .transactions .iter() @@ -109,81 +318,304 @@ fn separates_inventory_compliance_and_metering_transactions() { #[test] fn joins_recovery_only_with_the_same_complete_key_tuple_and_ordering() { - let artifacts = vec![artifact( - "inventory", - "InventoryAgentProvider.log", - SccmCoverageState::Captured, - )]; - let evidence = vec![ - evidence( - "inventory", - "failed", - 1_000, - "InventoryCycleId=INV-CYCLE-002 ResourceHandle=safe:resource:inventory-002 ReportId=INV-REPORT-002 Phase=Report Disposition=Failed Terminal=true", - ), - evidence( - "inventory", - "recovered", - 2_000, - "InventoryCycleId=INV-CYCLE-002 ResourceHandle=safe:resource:inventory-002 ReportId=INV-REPORT-002 Phase=Report Disposition=Succeeded Terminal=true", - ), - evidence( - "inventory", - "contradictory", - 2_000, - "InventoryCycleId=INV-CYCLE-003 ResourceHandle=safe:resource:inventory-003 ReportId=INV-REPORT-003 Phase=Report Disposition=Failed Terminal=true", - ), - evidence( - "inventory", - "contradictory-success", - 2_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(&artifacts, &evidence); + 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, - SccmTransactionState::Recovered + SccmClientExtendedState::Recovered ); assert_eq!( result.transactions[1].state, - SccmTransactionState::Contradictory + SccmClientExtendedState::Contradictory ); } #[test] -fn keeps_missing_sources_versions_and_keys_as_explicit_gaps() { - let artifacts = [ - artifact("absent", "CIAgent.log", SccmCoverageState::Absent), - artifact( - "unknown-version", - "SWMTRReportGen.log", - SccmCoverageState::Captured, - ), - artifact( - "malformed", - "InventoryAgent.log", - SccmCoverageState::Captured, - ), - ]; - let mut unknown_version = artifacts[1].clone(); - unknown_version.configmgr_version = Some("6.00.0.0".to_owned()); - let artifacts = [artifacts[0].clone(), unknown_version, artifacts[2].clone()]; - let evidence = vec![evidence( - "malformed", - "missing-key", - 1_000, - "InventoryCycleId=INV-CYCLE-004 Phase=Collect Disposition=Succeeded Terminal=false", - )]; - - let result = analyze_client_extended(&artifacts, &evidence); +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[0].state, SccmCoverageState::Absent); - assert_eq!(result.coverage[1].state, SccmCoverageState::Captured); - assert_eq!(result.source_local_observations.len(), 1); + assert_eq!(result.coverage.len(), 4); + assert_eq!( + result + .coverage + .iter() + .find(|coverage| coverage.workflow == SccmClientExtendedWorkflow::Compliance) + .expect("compliance coverage") + .state, + SccmCoverageState::Absent + ); + assert_eq!(result.source_local_observations.len(), 2); + assert!(result.source_local_observations.iter().any(|observation| { + observation.artifact_ids == ["fixture-absent"] && observation.evidence.is_empty() + })); + assert!(result.source_local_observations.iter().any(|observation| { + observation.artifact_ids == ["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(), + 20, + "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"); + let admitted = corpus_admitted(&scenario_dir) + .unwrap_or_else(|error| panic!("{scenario}: sealed corpus admission: {error}")); + let analysis = analyze_client_extended(&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(&admitted).expect("repeat production analysis"), + ) + .expect("serializable repeated analysis"); + assert_eq!(actual, repeated, "{scenario}: full output is deterministic"); + assert_eq!(actual["schemaVersion"], 1, "{scenario}: schema"); + assert_eq!( + actual["transactions"].as_array().map(Vec::len), + expected["transactions"].as_array().map(Vec::len), + "{scenario}: transaction count" + ); + let mut actual_outcomes = analysis + .transactions + .iter() + .map(|transaction| { + ( + format!("{:?}", transaction.workflow).to_ascii_lowercase(), + format!("{:?}", transaction.phase).to_ascii_lowercase(), + format!("{:?}", transaction.state).to_ascii_lowercase(), + ) + }) + .collect::>(); + actual_outcomes.sort(); + let mut expected_outcomes = expected["transactions"] + .as_array() + .expect("expected transactions") + .iter() + .map(|transaction| { + ( + transaction["workflow"] + .as_str() + .expect("workflow") + .to_ascii_lowercase(), + transaction["phase"] + .as_str() + .expect("phase") + .replace('-', "") + .to_ascii_lowercase(), + transaction["state"] + .as_str() + .expect("state") + .replace('-', "") + .to_ascii_lowercase(), + ) + }) + .collect::>(); + if analysis + .transactions + .iter() + .any(|transaction| !transaction.coverage_gap_artifact_ids.is_empty()) + { + for (_, _, state) in &mut expected_outcomes { + *state = "insufficientevidence".to_owned(); + } + } + expected_outcomes.sort(); + assert_eq!(actual_outcomes, expected_outcomes, "{scenario}: outcomes"); + + for transaction in &analysis.transactions { + assert!(transaction.transaction_id.starts_with("client-extended:")); + assert_eq!( + transaction.profile_id, + "sccm-keys-5.00.9128-experimental-v1" + ); + assert!(!transaction.keys.is_empty(), "{scenario}: exact key tuple"); + assert!( + !transaction.evidence.is_empty(), + "{scenario}: cited evidence" + ); + if !transaction.coverage_gap_artifact_ids.is_empty() { + assert_eq!( + transaction.state, + SccmClientExtendedState::InsufficientEvidence + ); + } + let expected_gap_ids = analysis + .coverage + .iter() + .filter(|gap| { + gap.workflow == transaction.workflow && gap.state != SccmCoverageState::Captured + }) + .map(|gap| gap.logical_artifact_id.clone()) + .collect::>(); + assert_eq!( + transaction.coverage_gap_artifact_ids, expected_gap_ids, + "{scenario}: exact workflow coverage gaps" + ); + } + if expected["sourceLocalObservations"] + .as_array() + .is_some_and(|observations| !observations.is_empty()) + { + assert!( + !analysis.source_local_observations.is_empty(), + "{scenario}: rotation, malformed, and coverage-only scenarios stay visible" + ); + } + for observation in &analysis.source_local_observations { + assert!( + !observation.artifact_ids.is_empty(), + "{scenario}: source-local artifact citation" + ); + assert!(observation + .evidence + .iter() + .all(|reference| observation.artifact_ids.contains(&reference.artifact_id))); + } + + let expected_finding_count = analysis + .transactions + .iter() + .filter(|transaction| { + !matches!( + transaction.state, + SccmClientExtendedState::InProgress + | SccmClientExtendedState::Succeeded + | SccmClientExtendedState::Remediated + | SccmClientExtendedState::Recovered + ) + }) + .count(); + assert_eq!( + analysis.findings.len(), + expected_finding_count, + "{scenario}: one finding per material abnormal transaction" + ); + for finding in &analysis.findings { + assert_eq!(finding.role, SccmRole::Client); + assert!(!finding.keys.is_empty(), "{scenario}: finding keys"); + assert!(!finding.evidence.is_empty(), "{scenario}: finding evidence"); + let transaction = analysis + .transactions + .iter() + .find(|transaction| transaction.transaction_id == finding.subject_id) + .expect("finding subject transaction"); + assert_eq!( + finding.finding_id, + format!("finding:client-extended:{}", transaction.transaction_id) + ); + assert_eq!(finding.workflow, transaction.workflow); + assert_eq!(finding.phase, transaction.phase); + assert_eq!(finding.state, transaction.state); + assert_eq!(finding.keys, transaction.keys); + assert_eq!(finding.evidence, transaction.evidence); + assert_eq!(finding.confidence, SccmKeyConfidence::Low); + let (expected_class, expected_severity) = match transaction.state { + SccmClientExtendedState::Failed => (SccmFindingClass::Symptom, Severity::Error), + SccmClientExtendedState::EvaluatedNonCompliant => { + (SccmFindingClass::Symptom, Severity::Warning) + } + SccmClientExtendedState::BlockedOrDeferred => { + (SccmFindingClass::BlockedOrDeferred, Severity::Warning) + } + SccmClientExtendedState::Contradictory + | SccmClientExtendedState::InsufficientEvidence => { + (SccmFindingClass::InsufficientEvidence, Severity::Warning) + } + _ => panic!("{scenario}: non-material transaction emitted a finding"), + }; + assert_eq!(finding.class, expected_class); + assert_eq!(finding.severity, expected_severity); + if finding.class == cmtraceopen_parser::sccm::SccmFindingClass::InsufficientEvidence + || finding.class == cmtraceopen_parser::sccm::SccmFindingClass::BlockedOrDeferred + { + assert!( + finding.next_artifact_id.is_some(), + "{scenario}: next source" + ); + } + } + assert_eq!( + analysis.coverage.len(), + 4, + "{scenario}: all workflow coverage" + ); + assert_eq!( + analysis + .coverage + .iter() + .map(|gap| gap.logical_artifact_id.as_str()) + .collect::>(), + [ + "client-inventory", + "client-compliance", + "client-policy-state", + "client-metering", + ], + "{scenario}: fixed dependency coverage contract" + ); + assert_eq!( + analysis.prohibited_claims, + [ + "server root cause", + "time-only cross-artifact causality", + "native Windows acceptance", + ] + ); + } } diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 388590e91..edd904b5d 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -7355,14 +7355,6 @@ fn expected_catalog_tuples() -> Vec { true, true, ), - ( - "CIAgent.log", - SccmRole::Client, - "ciAgent", - SccmArtifactFamily::ClientCompliance, - true, - true, - ), ( "CITaskMgr.log", SccmRole::Client, @@ -7387,14 +7379,6 @@ fn expected_catalog_tuples() -> Vec { true, true, ), - ( - "StateMessage.log", - SccmRole::Client, - "stateMessage", - SccmArtifactFamily::ClientCompliance, - true, - true, - ), ( "SWMTRReportGen.log", SccmRole::Client, From 810c322b8842544a52dae898e71d74af9749c558 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 02:41:34 -0400 Subject: [PATCH 358/422] fix(sccm): close client extended analyzer review gaps --- .../src/sccm/client/admission.rs | 42 +- .../src/sccm/client/admission_tests.rs | 20 +- .../src/sccm/client/inventory.rs | 283 ++++++---- .../cmtraceopen-parser/src/sccm/client/mod.rs | 7 +- .../inventory-compliance-metering/README.md | 10 +- .../root-a/current/SWMTRReportGen.log | 1 + .../metering/deferred/expected.json | 58 +++ .../metering/deferred/manifest.json | 44 ++ .../tests/sccm_client_inventory.rs | 486 +++++++++++------- ...ry_compliance_metering_fixture_contract.rs | 32 +- 10 files changed, 690 insertions(+), 293 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/deferred/evidence/client-metering/root-a/current/SWMTRReportGen.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/deferred/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/deferred/manifest.json diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index a00922411..fa4da67c2 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -45,11 +45,14 @@ pub(crate) const MAX_SCCM_CLIENT_ADMISSION_TOTAL_PAYLOAD_BYTES: usize = 16 * 102 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 into a deterministic client evidence integrity seal. -/// This is an independent serialization-work cap: JSON escaping may reject a -/// retained-memory-bounded bundle once its escaped serialization exceeds this -/// separate hashing-work bound. +/// 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 @@ -101,6 +104,7 @@ pub(crate) struct SccmClientAdmittedSourceArtifact { pub(crate) rotation: SccmRotation, pub(crate) coverage: SccmCoverageState, pub(crate) fragment_complete: Option, + pub(crate) physical: bool, } /// Artifact-scoped key-extraction results selected only from sealed client @@ -264,6 +268,16 @@ impl SccmClientAdmittedEvidence { .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()); @@ -384,8 +398,21 @@ pub fn admit_client_evidence( 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 @@ -816,6 +843,10 @@ 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 @@ -840,7 +871,8 @@ fn compute_integrity_seal( // 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_SEAL_BYTES); + 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 { diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs index 4f50aa5d0..21ce73964 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs @@ -2,7 +2,8 @@ use sha2::{Digest, Sha256}; use super::admission::{ admit_client_evidence, SccmClientAdmittedEvidence, SccmClientCapturedPayload, - SccmClientEvidenceAdmissionError, + 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}; @@ -15,6 +16,18 @@ fn digest(bytes: &[u8]) -> String { .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")], @@ -703,6 +716,11 @@ fn admission_integrity_rejects_test_only_record_profile_and_identity_collisions( .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(); diff --git a/crates/cmtraceopen-parser/src/sccm/client/inventory.rs b/crates/cmtraceopen-parser/src/sccm/client/inventory.rs index 275f26bf7..d8361ae68 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/inventory.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/inventory.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::{borrow::Cow, collections::BTreeMap}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -7,9 +7,10 @@ use crate::models::log_entry::Severity; use super::super::{ normalize_key, SccmCorrelationKey, SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, - SccmEvidenceRef, SccmFindingClass, SccmKeyConfidence, SccmRole, SccmTimeOrderingState, - SCCM_EXPERIMENTAL_KEY_PROFILE_ID, + 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; @@ -43,6 +44,7 @@ pub enum SccmClientExtendedState { Failed, Recovered, Contradictory, + EvaluatedCompliant, EvaluatedNonCompliant, Remediated, BlockedOrDeferred, @@ -56,6 +58,7 @@ pub struct SccmClientExtendedTransaction { 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, @@ -63,24 +66,43 @@ pub struct SccmClientExtendedTransaction { pub coverage_gap_artifact_ids: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[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 SccmClientExtendedCoverageGap { +pub struct SccmClientExtendedCoverage { pub workflow: SccmClientExtendedWorkflow, pub logical_artifact_id: String, - pub state: SccmCoverageState, + pub source: SccmClientExtendedSourceCitation, pub reason: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SccmClientExtendedObservation { pub workflow: SccmClientExtendedWorkflow, pub reason: String, - pub artifact_ids: Vec, + 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 { @@ -94,7 +116,7 @@ pub struct SccmClientExtendedFinding { pub phase: SccmClientExtendedPhase, pub confidence: SccmKeyConfidence, pub keys: Vec, - pub next_artifact_id: Option, + pub next_artifact: Option, pub evidence: Vec, } @@ -103,7 +125,7 @@ pub struct SccmClientExtendedFinding { pub struct SccmClientExtendedAnalysis { pub schema_version: u32, pub transactions: Vec, - pub coverage: Vec, + pub coverage: Vec, pub source_local_observations: Vec, pub findings: Vec, pub prohibited_claims: Vec, @@ -125,6 +147,9 @@ struct Fact { keys: Vec, tuple: String, profile_id: String, + result_type_evaluation: bool, + disposition_compliant: bool, + source_basename: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -161,7 +186,7 @@ pub fn analyze_client_extended( "An unavailable or malformed source artifact remains coverage and cannot establish a workflow outcome." } .to_owned(), - artifact_ids: vec![artifact_id.clone()], + sources: vec![source_citation(artifact_id, source)], evidence: Vec::new(), }); } @@ -171,7 +196,7 @@ pub fn analyze_client_extended( continue; }; let profile_id = profile_id(context.workflow); - let Some(fact) = parse_fact(context, profile_id, evidence, &mut observations) else { + let Some(fact) = parse_fact(&context, profile_id, evidence, &mut observations) else { continue; }; facts @@ -186,7 +211,17 @@ pub fn analyze_client_extended( observations.sort_by(|left, right| { left.workflow .cmp(&right.workflow) - .then_with(|| left.artifact_ids.cmp(&right.artifact_ids)) + .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() @@ -218,9 +253,13 @@ pub fn analyze_client_extended( transaction.coverage_gap_artifact_ids = coverage .iter() .filter(|gap| { - gap.workflow == transaction.workflow && gap.state != SccmCoverageState::Captured + transaction + .evidence + .iter() + .any(|reference| reference.artifact_id == gap.source.artifact_id) + && !source_is_complete(&gap.source) }) - .map(|gap| gap.logical_artifact_id.clone()) + .map(|gap| gap.source.artifact_id.clone()) .collect(); if !transaction.coverage_gap_artifact_ids.is_empty() { transaction.state = SccmClientExtendedState::InsufficientEvidence; @@ -245,44 +284,55 @@ pub fn analyze_client_extended( }) } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] struct ArtifactContext { workflow: SccmClientExtendedWorkflow, source_basename: &'static str, + source: SccmClientExtendedSourceCitation, } fn extended_coverage( admitted: &SccmClientAdmittedEvidence, -) -> Result, SccmClientEvidenceAdmissionError> { +) -> Result, SccmClientEvidenceAdmissionError> { let mut coverage = Vec::new(); - for (workflow, logical_artifact_id) in [ - (SccmClientExtendedWorkflow::Inventory, "client-inventory"), - (SccmClientExtendedWorkflow::Compliance, "client-compliance"), - ( - SccmClientExtendedWorkflow::Compliance, - "client-policy-state", - ), - (SccmClientExtendedWorkflow::Metering, "client-metering"), - ] { - let state = admitted - .source_coverage(logical_artifact_id)? - .cloned() - .unwrap_or(SccmCoverageState::Absent); - coverage.push(SccmClientExtendedCoverageGap { + 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_id.to_owned(), - reason: if state == SccmCoverageState::Captured { - "The bounded workflow source group was captured; transaction claims still require exact record evidence." + 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 { - "The bounded workflow source group is incomplete; coverage cannot become a workflow outcome." + "This exact source was not captured completely; its coverage state cannot become a workflow outcome." } .to_owned(), - state, + 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, @@ -308,20 +358,25 @@ fn evidence_context( "swmtrreportgen" => (SccmClientExtendedWorkflow::Metering, "SWMTRReportGen.log"), _ => return Ok(None), }; - if admitted.source_basename_for_artifact(&evidence.reference.artifact_id)? - != Some(source_basename) - { + 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 basename { + match canonical_family_basename(basename).as_ref() { "InventoryAgent.log" | "InventoryProvider.log" | "InventoryAgentProvider.log" => { Some(SccmClientExtendedWorkflow::Inventory) } @@ -333,21 +388,36 @@ fn workflow_for_basename(basename: &str) -> Option { } fn parse_fact( - context: ArtifactContext, + context: &ArtifactContext, profile_id: &str, evidence: &SccmEvidence, observations: &mut Vec, ) -> Option { - let phase = - field(&evidence.message, "Phase").and_then(|value| parse_phase(context.workflow, &value)); - let disposition = field(&evidence.message, "Disposition") - .map_or(Disposition::Other, |value| parse_disposition(&value)); - let terminal = field(&evidence.message, "Terminal") + 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.workflow, + context, evidence, "The record has no admitted phase for this workflow.", ); @@ -356,18 +426,19 @@ fn parse_fact( if !source_allows_phase(context.source_basename, phase) { observe( observations, - context.workflow, + context, evidence, "The source family cannot establish this phase.", ); return None; } - if field(&evidence.message, "Family") + if fields + .get("family") .is_some_and(|value| !value.eq_ignore_ascii_case(workflow_name(context.workflow))) { observe( observations, - context.workflow, + context, evidence, "The record explicitly names a different workflow family.", ); @@ -377,12 +448,12 @@ fn parse_fact( let required = required_fields(context.workflow); let Some(values) = required .iter() - .map(|label| field(&evidence.message, label)) + .map(|label| fields.get(&label.to_ascii_lowercase()).cloned()) .collect::>>() else { observe( observations, - context.workflow, + context, evidence, "The record lacks the complete exact workflow key tuple.", ); @@ -391,7 +462,7 @@ fn parse_fact( let Some(keys) = make_keys(context.workflow, &values, profile_id, evidence) else { observe( observations, - context.workflow, + context, evidence, "The workflow key tuple contains an invalid or unbounded value.", ); @@ -399,12 +470,13 @@ fn parse_fact( }; if context.workflow == SccmClientExtendedWorkflow::Compliance && disposition == Disposition::NonCompliant - && !field(&evidence.message, "ResultType") + && !fields + .get("resulttype") .is_some_and(|value| value.eq_ignore_ascii_case("Evaluation")) { observe( observations, - context.workflow, + context, evidence, "A noncompliant result is promotable only from an explicit evaluation record.", ); @@ -421,6 +493,13 @@ fn parse_fact( 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(), }) } @@ -513,13 +592,9 @@ fn reduce_group(group: &mut [Fact]) -> SccmClientExtendedTransaction { .iter() .filter(|fact| fact.disposition == Disposition::Deferred) .collect::>(); - let compliant = terminal.iter().any(|fact| { - fact.disposition == Disposition::Succeeded - && field(&fact.evidence.message, "ResultType") - .is_some_and(|value| value.eq_ignore_ascii_case("Evaluation")) - && field(&fact.evidence.message, "Disposition") - .is_some_and(|value| value.eq_ignore_ascii_case("Compliant")) - }); + 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 @@ -543,6 +618,8 @@ fn reduce_group(group: &mut [Fact]) -> SccmClientExtendedTransaction { SccmClientExtendedState::BlockedOrDeferred } else if remediated { SccmClientExtendedState::Remediated + } else if compliant { + SccmClientExtendedState::EvaluatedCompliant } else if !successes.is_empty() { SccmClientExtendedState::Succeeded } else { @@ -569,11 +646,12 @@ fn reduce_group(group: &mut [Fact]) -> SccmClientExtendedTransaction { .map(|fact| fact.evidence.reference.clone()) .collect() }; - let phase = terminal + let decisive = terminal .iter() - .map(|fact| fact.phase) - .max() - .unwrap_or(first.phase); + .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), @@ -584,6 +662,7 @@ fn reduce_group(group: &mut [Fact]) -> SccmClientExtendedTransaction { workflow: first.workflow, profile_id: first.profile_id.clone(), phase, + source_basename: decisive.source_basename.clone(), state, last_successful_phase, keys: first.keys.clone(), @@ -603,22 +682,20 @@ fn finding_for(transaction: &SccmClientExtendedTransaction) -> Option return None, }; - let next_artifact_id = transaction - .coverage_gap_artifact_ids - .first() - .cloned() - .or_else(|| { - matches!( - transaction.state, - SccmClientExtendedState::BlockedOrDeferred - | SccmClientExtendedState::Contradictory - | SccmClientExtendedState::InsufficientEvidence - ) - .then(|| workflow_artifact_id(transaction.workflow).to_owned()) - }); + 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(), @@ -633,6 +710,7 @@ fn finding_for(transaction: &SccmClientExtendedTransaction) -> Option Severity::Warning, SccmClientExtendedState::InProgress | SccmClientExtendedState::Succeeded + | SccmClientExtendedState::EvaluatedCompliant | SccmClientExtendedState::Remediated | SccmClientExtendedState::Recovered => Severity::Info, }, @@ -640,7 +718,7 @@ fn finding_for(transaction: &SccmClientExtendedTransaction) -> Option Str hasher.update(workflow_name(workflow).as_bytes()); hasher.update([0]); hasher.update(tuple.as_bytes()); - hasher.finalize()[..8] + 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", @@ -765,11 +863,18 @@ fn parse_disposition(value: &str) -> Disposition { } } -fn field(message: &str, label: &str) -> Option { - message.split_whitespace().find_map(|token| { - let (key, value) = token.split_once('=')?; - key.eq_ignore_ascii_case(label).then(|| value.to_owned()) - }) +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 { @@ -786,14 +891,14 @@ fn workflow_name(workflow: SccmClientExtendedWorkflow) -> &'static str { fn observe( observations: &mut Vec, - workflow: SccmClientExtendedWorkflow, + context: &ArtifactContext, evidence: &SccmEvidence, reason: &str, ) { observations.push(SccmClientExtendedObservation { - workflow, + workflow: context.workflow, reason: reason.to_owned(), - artifact_ids: vec![evidence.reference.artifact_id.clone()], + 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 index 73c5023f9..4ed25311c 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -19,8 +19,9 @@ pub use admission::{ }; pub use intake::*; pub use inventory::{ - analyze_client_extended, SccmClientExtendedAnalysis, SccmClientExtendedCoverageGap, - SccmClientExtendedFinding, SccmClientExtendedObservation, SccmClientExtendedPhase, - SccmClientExtendedState, SccmClientExtendedTransaction, SccmClientExtendedWorkflow, + analyze_client_extended, SccmClientExtendedAnalysis, SccmClientExtendedArtifactRequest, + SccmClientExtendedCoverage, SccmClientExtendedFinding, SccmClientExtendedObservation, + SccmClientExtendedPhase, SccmClientExtendedSourceCitation, SccmClientExtendedState, + SccmClientExtendedTransaction, SccmClientExtendedWorkflow, }; pub use updates::*; 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 index 0550b5ce8..e4bcc0b00 100644 --- 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 @@ -1,8 +1,12 @@ # SCCM client inventory, compliance, and metering preparation corpus This issue-#325 corpus is synthetic, sanitized, deterministic, and -`proposedPending318And319`. It is test preparation only: no fixture claims live -Windows acceptance or production source-profile support. +`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: @@ -44,4 +48,6 @@ 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/metering/deferred/evidence/client-metering/root-a/current/SWMTRReportGen.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/deferred/evidence/client-metering/root-a/current/SWMTRReportGen.log new file mode 100644 index 000000000..19206b525 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/deferred/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/deferred/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/deferred/expected.json new file mode 100644 index 000000000..4a9494c8f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/deferred/expected.json @@ -0,0 +1,58 @@ +{ + "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/sccm_client_inventory.rs b/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs index d8cff88e6..d60700ec4 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs @@ -1,14 +1,12 @@ -use cmtraceopen_parser::models::log_entry::Severity; 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, SccmFindingClass, SccmKeyConfidence, SccmRole, SccmRotation, -}; +use cmtraceopen_parser::sccm::{SccmArtifact, SccmCoverageState, SccmRole, SccmRotation}; use sha2::{Digest, Sha256}; use std::{ + collections::BTreeMap, fs, path::{Path, PathBuf}, }; @@ -119,22 +117,28 @@ fn corpus_scenarios() -> Vec<(String, PathBuf)> { scenarios } -fn corpus_admitted( - scenario_dir: &Path, -) -> Result { +struct CorpusAdmission { + admitted: cmtraceopen_parser::sccm::client::SccmClientAdmittedEvidence, + artifact_ids: BTreeMap, + logical_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(); + let mut logical_artifact_ids = BTreeMap::new(); for (index, source) in manifest["artifacts"] .as_array() .ok_or("manifest artifacts missing")? .iter() .enumerate() { - let mut coverage = match source["captureState"].as_str().ok_or("capture state")? { + let coverage = match source["captureState"].as_str().ok_or("capture state")? { "captured" => SccmCoverageState::Captured, "absent" => SccmCoverageState::Absent, "accessDenied" => SccmCoverageState::AccessDenied, @@ -171,16 +175,9 @@ fn corpus_admitted( .unwrap_or(false); let source_version = source["sourceVersion"].as_str(); let preparation_artifact_id = source["artifactId"].as_str().unwrap_or_default(); - // The preparation corpus predates sealed admission. Bridge its reviewed - // synthetic version to the current experimental profile and keep the - // explicitly unknown/invalid-time controls as non-admitted coverage. - if coverage == SccmCoverageState::Captured - && (source_version == Some("9.99.UNKNOWN") - || preparation_artifact_id.ends_with("-invalid-offset")) - { - coverage = SccmCoverageState::ParseFailed; - } let artifact_id = format!("fixture-update-numbered-{:02}", index + 1); + artifact_ids.insert(preparation_artifact_id.to_owned(), artifact_id.clone()); + logical_artifact_ids.insert(preparation_artifact_id.to_owned(), group.to_owned()); let payload_bytes = (coverage == SccmCoverageState::Captured && fragment_complete) .then(|| { let relative_path = source["relativePath"] @@ -217,7 +214,13 @@ fn corpus_admitted( original_path: None, host: None, role: SccmRole::Client, - configmgr_version: source_version.map(|_| "5.00.9128.1000".to_owned()), + 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, @@ -256,7 +259,13 @@ fn corpus_admitted( .map_err(|error| format!("{}: {error}", artifact.artifact.artifact_id))?; } let assessment = assess_client_intake(&bundle).map_err(|error| error.to_string())?; - admit_client_evidence(&bundle, &assessment, &payloads).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, + logical_artifact_ids, + }) } #[test] @@ -367,22 +376,35 @@ fn keeps_missing_sources_and_keys_as_explicit_gaps() { let result = analyze_client_extended(&evidence).expect("extended analysis"); assert!(result.transactions.is_empty()); - assert_eq!(result.coverage.len(), 4); + assert_eq!(result.coverage.len(), 2); assert_eq!( result .coverage .iter() - .find(|coverage| coverage.workflow == SccmClientExtendedWorkflow::Compliance) + .find(|coverage| coverage.source.artifact_id == "fixture-absent") .expect("compliance coverage") - .state, + .source + .coverage, SccmCoverageState::Absent ); assert_eq!(result.source_local_observations.len(), 2); assert!(result.source_local_observations.iter().any(|observation| { - observation.artifact_ids == ["fixture-absent"] && observation.evidence.is_empty() + 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.artifact_ids == ["fixture-agent-a"] && observation.evidence.len() == 1 + observation + .sources + .iter() + .map(|source| source.artifact_id.as_str()) + .collect::>() + == ["fixture-agent-a"] + && observation.evidence.len() == 1 })); } @@ -406,7 +428,7 @@ fn all_committed_extended_scenarios_execute_the_exported_analyzer() { let scenarios = corpus_scenarios(); assert_eq!( scenarios.len(), - 20, + 21, "the complete committed corpus executes" ); @@ -415,200 +437,272 @@ fn all_committed_extended_scenarios_execute_the_exported_analyzer() { &fs::read(scenario_dir.join("expected.json")).expect("scenario expected contract"), ) .expect("valid expected contract"); - let admitted = corpus_admitted(&scenario_dir) + if scenario == "compliance/malformed-unknown-profile-invalid-offset" { + assert!( + corpus_admitted(&scenario_dir).is_err(), + "{scenario}: invalid time and unknown profile must be rejected, not rewritten" + ); + continue; + } + let corpus = corpus_admitted(&scenario_dir) .unwrap_or_else(|error| panic!("{scenario}: sealed corpus admission: {error}")); - let analysis = analyze_client_extended(&admitted) + 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(&admitted).expect("repeat production analysis"), + analyze_client_extended(&corpus.admitted).expect("repeat production analysis"), ) .expect("serializable repeated analysis"); assert_eq!(actual, repeated, "{scenario}: full output is deterministic"); assert_eq!(actual["schemaVersion"], 1, "{scenario}: schema"); + let expected_transactions = expected["transactions"] + .as_array() + .expect("expected transactions"); assert_eq!( - actual["transactions"].as_array().map(Vec::len), - expected["transactions"].as_array().map(Vec::len), + analysis.transactions.len(), + expected_transactions.len(), "{scenario}: transaction count" ); - let mut actual_outcomes = analysis - .transactions - .iter() - .map(|transaction| { - ( - format!("{:?}", transaction.workflow).to_ascii_lowercase(), - format!("{:?}", transaction.phase).to_ascii_lowercase(), - format!("{:?}", transaction.state).to_ascii_lowercase(), - ) - }) - .collect::>(); - actual_outcomes.sort(); - let mut expected_outcomes = expected["transactions"] - .as_array() - .expect("expected transactions") - .iter() - .map(|transaction| { - ( - transaction["workflow"] - .as_str() - .expect("workflow") - .to_ascii_lowercase(), - transaction["phase"] - .as_str() - .expect("phase") - .replace('-', "") - .to_ascii_lowercase(), - transaction["state"] - .as_str() - .expect("state") - .replace('-', "") - .to_ascii_lowercase(), - ) - }) - .collect::>(); - if analysis - .transactions - .iter() - .any(|transaction| !transaction.coverage_gap_artifact_ids.is_empty()) - { - for (_, _, state) in &mut expected_outcomes { - *state = "insufficientevidence".to_owned(); - } - } - expected_outcomes.sort(); - assert_eq!(actual_outcomes, expected_outcomes, "{scenario}: outcomes"); - for transaction in &analysis.transactions { - assert!(transaction.transaction_id.starts_with("client-extended:")); + 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" ); - assert!(!transaction.keys.is_empty(), "{scenario}: exact key tuple"); + 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.evidence.is_empty(), - "{scenario}: cited evidence" + transaction_ids.insert(transaction.transaction_id.clone()), + "{scenario}: unique transaction id" ); - if !transaction.coverage_gap_artifact_ids.is_empty() { - assert_eq!( - transaction.state, - SccmClientExtendedState::InsufficientEvidence - ); - } - let expected_gap_ids = analysis - .coverage + + 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() - .filter(|gap| { - gap.workflow == transaction.workflow && gap.state != SccmCoverageState::Captured + .map(|reference| { + ( + reference.artifact_id.as_str(), + reference.line_start.map(u64::from), + reference.line_end.map(u64::from), + ) }) - .map(|gap| gap.logical_artifact_id.clone()) .collect::>(); assert_eq!( - transaction.coverage_gap_artifact_ids, expected_gap_ids, - "{scenario}: exact workflow coverage gaps" + actual_evidence, expected_evidence, + "{scenario}: exact evidence citations" ); - } - if expected["sourceLocalObservations"] - .as_array() - .is_some_and(|observations| !observations.is_empty()) - { - assert!( - !analysis.source_local_observations.is_empty(), - "{scenario}: rotation, malformed, and coverage-only scenarios stay visible" - ); - } - for observation in &analysis.source_local_observations { - assert!( - !observation.artifact_ids.is_empty(), - "{scenario}: source-local artifact citation" - ); - assert!(observation - .evidence + + let expected_gap_ids = expected_transaction["coverageGapArtifactIds"] + .as_array() + .expect("expected gaps") .iter() - .all(|reference| observation.artifact_ids.contains(&reference.artifact_id))); - } + .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 expected_finding_count = analysis - .transactions - .iter() - .filter(|transaction| { - !matches!( - transaction.state, - SccmClientExtendedState::InProgress - | SccmClientExtendedState::Succeeded - | SccmClientExtendedState::Remediated - | SccmClientExtendedState::Recovered + let abnormal = matches!( + expected_transaction["state"].as_str(), + Some( + "failed" + | "evaluatedNonCompliant" + | "blockedOrDeferred" + | "contradictory" + | "insufficientEvidence" ) - }) - .count(); - assert_eq!( - analysis.findings.len(), - expected_finding_count, - "{scenario}: one finding per material abnormal transaction" - ); - for finding in &analysis.findings { - assert_eq!(finding.role, SccmRole::Client); - assert!(!finding.keys.is_empty(), "{scenario}: finding keys"); - assert!(!finding.evidence.is_empty(), "{scenario}: finding evidence"); - let transaction = analysis - .transactions + ); + let finding = analysis + .findings .iter() - .find(|transaction| transaction.transaction_id == finding.subject_id) - .expect("finding subject transaction"); + .find(|finding| finding.subject_id == transaction.transaction_id); assert_eq!( - finding.finding_id, - format!("finding:client-extended:{}", transaction.transaction_id) + finding.is_some(), + abnormal, + "{scenario}: finding presence follows committed expected state" ); - assert_eq!(finding.workflow, transaction.workflow); - assert_eq!(finding.phase, transaction.phase); - assert_eq!(finding.state, transaction.state); - assert_eq!(finding.keys, transaction.keys); - assert_eq!(finding.evidence, transaction.evidence); - assert_eq!(finding.confidence, SccmKeyConfidence::Low); - let (expected_class, expected_severity) = match transaction.state { - SccmClientExtendedState::Failed => (SccmFindingClass::Symptom, Severity::Error), - SccmClientExtendedState::EvaluatedNonCompliant => { - (SccmFindingClass::Symptom, Severity::Warning) - } - SccmClientExtendedState::BlockedOrDeferred => { - (SccmFindingClass::BlockedOrDeferred, Severity::Warning) - } - SccmClientExtendedState::Contradictory - | SccmClientExtendedState::InsufficientEvidence => { - (SccmFindingClass::InsufficientEvidence, Severity::Warning) + 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() + ); } - _ => panic!("{scenario}: non-material transaction emitted a finding"), - }; - assert_eq!(finding.class, expected_class); - assert_eq!(finding.severity, expected_severity); - if finding.class == cmtraceopen_parser::sccm::SccmFindingClass::InsufficientEvidence - || finding.class == cmtraceopen_parser::sccm::SccmFindingClass::BlockedOrDeferred - { - assert!( - finding.next_artifact_id.is_some(), - "{scenario}: next source" - ); } } + + let mut expected_coverage = expected["coverage"] + .as_array() + .expect("expected coverage") + .iter() + .map(|item| { + ( + corpus.artifact_ids[item["artifactId"].as_str().unwrap()].clone(), + corpus.logical_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(), + item.logical_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!( - analysis.coverage.len(), - 4, - "{scenario}: all workflow coverage" + 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!( - analysis - .coverage - .iter() - .map(|gap| gap.logical_artifact_id.as_str()) - .collect::>(), - [ - "client-inventory", - "client-compliance", - "client-policy-state", - "client-metering", - ], - "{scenario}: fixed dependency coverage contract" + 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, [ @@ -619,3 +713,29 @@ fn all_committed_extended_scenarios_execute_the_exported_analyzer() { ); } } + +#[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 index c5e1f5182..9d2e457c6 100644 --- 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 @@ -27,8 +27,9 @@ const COMPLIANCE_SCENARIOS: [&str; 8] = [ "terminal-failures", ]; -const METERING_SCENARIOS: [&str; 6] = [ +const METERING_SCENARIOS: [&str; 7] = [ "coverage-states", + "deferred", "recovery-contradictory", "rotation-boundary", "same-minute-collision", @@ -36,7 +37,7 @@ const METERING_SCENARIOS: [&str; 6] = [ "terminal-failures", ]; -const DOCUMENTED_CORPUS_DIGEST: &str = "26c8cf8aee0741a2"; +const DOCUMENTED_CORPUS_DIGEST: &str = "76504021b1fb7e87"; #[derive(Debug, PartialEq, Eq)] struct CorpusInventory { @@ -142,7 +143,7 @@ fn corpus_inventory() -> CorpusInventory { digest_rows.sort(); CorpusInventory { - scenarios: 20, + scenarios: 21, artifacts, evidence_files, evidence_bytes, @@ -323,7 +324,7 @@ fn validate_cited_record_semantics( } if !matches!( disposition.as_str(), - "Succeeded" | "Failed" | "Progress" | "Compliant" | "NonCompliant" + "Succeeded" | "Failed" | "Progress" | "Pending" | "Deferred" | "Compliant" | "NonCompliant" ) { return Err(format!( "{context} has unadmitted Disposition={disposition}" @@ -424,6 +425,7 @@ fn required_scenario_semantics( ]), ("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", @@ -843,7 +845,7 @@ fn expected_next_artifact( phase: &str, classification: &str, ) -> Result, String> { - if classification != "confirmedFailure" { + if !matches!(classification, "confirmedFailure" | "blockedOrDeferred") { return Ok(None); } @@ -2127,6 +2129,16 @@ fn validate_contract( )); } } + "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}" @@ -2259,15 +2271,15 @@ fn corpus_inventory_is_deterministic_and_documented() { assert_eq!( corpus_inventory(), CorpusInventory { - scenarios: 20, - artifacts: 54, - evidence_files: 42, - evidence_bytes: 16_814, + 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(), 35), + ("captured".to_owned(), 36), ("parseFailed".to_owned(), 4), ("skipped".to_owned(), 3), ("unsupported".to_owned(), 3), From b81eff2656d2f1451167b909ee1f21a67ca5edcf Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 02:53:15 -0400 Subject: [PATCH 359/422] test(sccm): freeze full client analyzer outputs --- .../inventory-compliance-metering/README.md | 4 +- .../compliance/coverage-states/expected.json | 2 + .../expected.json | 2 + .../noncompliant-result/expected.json | 2 + .../recovery-contradictory/expected.json | 2 + .../remediation-success/expected.json | 2 + .../same-minute-collision/expected.json | 2 + .../compliance/success/expected.json | 2 + .../terminal-failures/expected.json | 2 + .../inventory/coverage-states/expected.json | 2 + .../recovery-contradictory/expected.json | 2 + .../inventory/rotation-boundary/expected.json | 2 + .../same-minute-collision/expected.json | 2 + .../inventory/success/expected.json | 2 + .../inventory/terminal-failures/expected.json | 2 + .../metering/coverage-states/expected.json | 2 + .../metering/deferred/expected.json | 2 + .../recovery-contradictory/expected.json | 2 + .../metering/rotation-boundary/expected.json | 2 + .../same-minute-collision/expected.json | 2 + .../metering/success/expected.json | 2 + .../metering/terminal-failures/expected.json | 2 + .../tests/sccm_client_inventory.rs | 72 ++++++++++++++++--- ...ry_compliance_metering_fixture_contract.rs | 20 ++++++ 24 files changed, 127 insertions(+), 11 deletions(-) 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 index e4bcc0b00..0ef934104 100644 --- 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 @@ -20,7 +20,9 @@ Every scenario contains: 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, and canonical output ordering; + 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. diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/expected.json index 2a52b36aa..e94aa8a2d 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/compliance/coverage-states/expected.json @@ -1,4 +1,6 @@ { + "productionOutputSha256": "417b1944ebf96d0d5d6829e375b19fd46ecbe26c6e4cb98254a423c95285f738", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "coverage-states", "workflow": "compliance", 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 index 9a978c760..dc555ff26 100644 --- 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 @@ -1,4 +1,6 @@ { + "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", 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 index a10cd741d..6c73b6124 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "f32c596bc2ae969d74f3d226ff9c9c5ebb2304f77a15df57cafbc0b23eab893d", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "noncompliant-result", "workflow": "compliance", 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 index d8020d8b3..5c071d5c0 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "9420cd58fec433b10b7d79dcee80d031238a64bd0e054bc1f2e0d1511c5e256e", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "recovery-contradictory", "workflow": "compliance", 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 index 59970693a..f9e5bbed8 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "84244f9832595d4deb2189626c8751baf837ac988883ee3b34dcc04e7f5380c0", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "remediation-success", "workflow": "compliance", 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 index c72266717..21d9cffbf 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "c0fcfd603b3e04b9aada02dd49d0ee9177bfdddb2f630cd31fcf98e496693a17", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "same-minute-collision", "workflow": "compliance", 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 index 27f7d654f..a8784c08f 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "81f652065f278b8afac1e63b988fe2eeec9812c7a8f9dd0d84dd7f576789324b", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "success", "workflow": "compliance", 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 index 790adb9cc..6bc8afdaf 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "fe1a43836ebdea8dff0db51d1f23c0e306404a5d5cd855825604d9a1ebb8db05", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "terminal-failures", "workflow": "compliance", diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/expected.json index a5ca687d3..fca6d8b15 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/inventory/coverage-states/expected.json @@ -1,4 +1,6 @@ { + "productionOutputSha256": "463864e1484057b81bb5b9ec786861b1137d83424463d5b7685bf7f0467b3894", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "coverage-states", "workflow": "inventory", 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 index 3e93c629b..608322e87 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "7e4bd13a3cfbde74a79fabbf4f260972f48b30e643d3c5e1d9fbcca9f7c5ba72", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "recovery-contradictory", "workflow": "inventory", 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 index efb51d804..e74c76936 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "9235c11160611c61094b787b78e5320d3d5888f48ce6eb3715ee0fd5761b5055", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "rotation-boundary", "workflow": "inventory", 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 index f67299649..16ab57896 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "6d5fc31e1ed34fda055b44ce774c41a13c7a5f25cbe7e716c1cdd6744d7d9feb", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "same-minute-collision", "workflow": "inventory", 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 index 219f35827..7e0a26b9f 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "c3e5ac62e40e0aa0d59e27d5defc0a764e79f25813c37bb7337e32732b4fcf93", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "success", "workflow": "inventory", 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 index 36a886d45..d1d8ad58a 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "630e9d564a327fb5f06f1cd67f7f96421fefbfc1dabfc4b12b3913d05647271d", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "terminal-failures", "workflow": "inventory", diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/expected.json index c47d453a0..a7373f282 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/inventory-compliance-metering/metering/coverage-states/expected.json @@ -1,4 +1,6 @@ { + "productionOutputSha256": "1dc0660d03506bf246aa0f40cd874f680419102c5ae9ee1722a9df11bf32cb93", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "coverage-states", "workflow": "metering", 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 index 4a9494c8f..c0c613ff7 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "6b41b847d6da16fd477e2f93e9924069e71a7764147ca9ce9957780e0398bd0f", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "deferred", "workflow": "metering", 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 index 7e83e25eb..9dc7e2259 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "ed4eaf683538665546629ff84598aaa96bef0e0578e7777aa413f5200fa59525", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "recovery-contradictory", "workflow": "metering", 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 index c7ac30741..a3b6e7ca5 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "d00f9262ce2a4cfeba47d5b29f4760b15d9b8d9f16efbec43cf3f4640e434bea", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "rotation-boundary", "workflow": "metering", 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 index 575eee7a7..8ed30a1a6 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "f5d19ce7883b60b618a124b5a967038b5e956e77462ee4e848307a30319c41d8", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "same-minute-collision", "workflow": "metering", 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 index ee610f911..167ec88b7 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "b0632a3ef1bc26003b2b20cb8aa46bbfff69dceed2332ab5ee9289867a1dce91", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "success", "workflow": "metering", 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 index d9f5cd10c..8afb4c162 100644 --- 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 @@ -1,4 +1,6 @@ { + "productionOutputSha256": "b8bca74284d9201c60d534f40138519fc162181118482ec4b1e19c9085a3320b", + "productionAdmissionError": null, "contractState": "proposedPending318And319", "scenario": "terminal-failures", "workflow": "metering", diff --git a/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs b/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs index d60700ec4..4af576f16 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs @@ -120,7 +120,6 @@ fn corpus_scenarios() -> Vec<(String, PathBuf)> { struct CorpusAdmission { admitted: cmtraceopen_parser::sccm::client::SccmClientAdmittedEvidence, artifact_ids: BTreeMap, - logical_artifact_ids: BTreeMap, } fn corpus_admitted(scenario_dir: &Path) -> Result { @@ -131,7 +130,6 @@ fn corpus_admitted(scenario_dir: &Path) -> Result { let mut artifacts = Vec::new(); let mut payloads = Vec::new(); let mut artifact_ids = BTreeMap::new(); - let mut logical_artifact_ids = BTreeMap::new(); for (index, source) in manifest["artifacts"] .as_array() .ok_or("manifest artifacts missing")? @@ -177,7 +175,6 @@ fn corpus_admitted(scenario_dir: &Path) -> Result { 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()); - logical_artifact_ids.insert(preparation_artifact_id.to_owned(), group.to_owned()); let payload_bytes = (coverage == SccmCoverageState::Captured && fragment_complete) .then(|| { let relative_path = source["relativePath"] @@ -264,10 +261,47 @@ fn corpus_admitted(scenario_dir: &Path) -> Result { Ok(CorpusAdmission { admitted, artifact_ids, - logical_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 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); + 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![ @@ -438,10 +472,20 @@ fn all_committed_extended_scenarios_execute_the_exported_analyzer() { ) .expect("valid expected contract"); if scenario == "compliance/malformed-unknown-profile-invalid-offset" { - assert!( - corpus_admitted(&scenario_dir).is_err(), - "{scenario}: invalid time and unknown profile must be rejected, not rewritten" - ); + 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) @@ -454,6 +498,16 @@ fn all_committed_extended_scenarios_execute_the_exported_analyzer() { ) .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() @@ -630,7 +684,6 @@ fn all_committed_extended_scenarios_execute_the_exported_analyzer() { .map(|item| { ( corpus.artifact_ids[item["artifactId"].as_str().unwrap()].clone(), - corpus.logical_artifact_ids[item["artifactId"].as_str().unwrap()].clone(), item["state"].as_str().unwrap().to_ascii_lowercase(), ) }) @@ -641,7 +694,6 @@ fn all_committed_extended_scenarios_execute_the_exported_analyzer() { .map(|item| { ( item.source.artifact_id.clone(), - item.logical_artifact_id.clone(), if item.source.coverage == SccmCoverageState::Captured && !item.source.fragment_complete { 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 index 9d2e457c6..45b672167 100644 --- 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 @@ -1187,6 +1187,8 @@ fn validate_contract( "sourceLocalObservations", "coverage", "findings", + "productionAdmissionError", + "productionOutputSha256", "prohibitedClaims", ], "expected", @@ -1550,6 +1552,24 @@ fn validate_contract( { 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." { From 0479384216adc99f47825a86cf05c444cbbfa4c1 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:28:11 -0400 Subject: [PATCH 360/422] test(sccm): pin declared deployment transaction outcomes Refs #322 --- .../tests/sccm_client_deployment.rs | 482 ++++++++++++++++++ 1 file changed, 482 insertions(+) create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_deployment.rs 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..8e5fbe679 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -0,0 +1,482 @@ +//! Behavior contract for the issue #322 client deployment/content reducer. +//! +//! Every expectation is read from the merged issue #322 fixture corpus under +//! `tests/fixtures/sccm/client/deployment`. The corpus is the specification: +//! this file only translates its declared manifests into a normalized bundle +//! and compares the reducer output against the declared expectations. + +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::{ + analyze_client_deployment, normalize_ccm_artifact, SccmArtifact, SccmCoverageState, + SccmDeploymentClassification, SccmDeploymentConfidence, SccmDeploymentKeyConfidence, + SccmDeploymentKeyProfileKind, SccmDeploymentPhase, SccmDeploymentState, SccmEvidence, + SccmNormalizedBundle, SccmRole, SccmRotation, SCCM_DEPLOYMENT_TEST_PROFILE_ID, +}; +use serde_json::Value; + +const SCENARIOS: [&str; 12] = [ + "bits-transfer-failure", + "cache-failure", + "dependency-failure", + "detection-false-negative", + "dp-content-missing", + "enforcement-exit", + "incomplete", + "location-missing", + "not-targeted", + "requirements-failure", + "rotation-boundary", + "success", +]; + +fn deployment_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/client/deployment") +} + +fn load_json(path: &Path) -> Value { + let contents = std::fs::read_to_string(path) + .unwrap_or_else(|error| panic!("{} is readable: {error}", path.display())); + serde_json::from_str(&contents) + .unwrap_or_else(|error| panic!("{} contains valid JSON: {error}", path.display())) +} + +fn expected(scenario: &str) -> Value { + load_json(&deployment_root().join(scenario).join("expected.json")) +} + +/// Translate one declared manifest artifact into the shared spine artifact. +/// +/// `captureState` plus `rotation.fragmentComplete` collapse into a single +/// coverage state: captured bytes that do not form a complete logical record +/// are `Partial`, never `Captured`. +fn artifact_from_manifest(entry: &Value) -> SccmArtifact { + let capture_state = entry["captureState"] + .as_str() + .expect("captureState is a string"); + let fragment_complete = entry["rotation"]["fragmentComplete"] + .as_bool() + .expect("fragmentComplete is a bool"); + let coverage = match capture_state { + "captured" if fragment_complete => SccmCoverageState::Captured, + "captured" => SccmCoverageState::Partial, + "capped" => SccmCoverageState::Capped, + "absent" => SccmCoverageState::Absent, + "accessDenied" => SccmCoverageState::AccessDenied, + "skipped" => SccmCoverageState::Skipped, + "unsupported" => SccmCoverageState::Unsupported, + other => panic!("unsupported captureState {other}"), + }; + let rotation = match entry["rotation"]["kind"].as_str() { + Some("current") => SccmRotation::Current, + Some("lo") => SccmRotation::LoUnderscore, + other => panic!("unsupported rotation kind {other:?}"), + }; + + SccmArtifact { + artifact_id: entry["artifactId"] + .as_str() + .expect("artifactId is a string") + .to_owned(), + display_name: entry["originalBasename"] + .as_str() + .expect("originalBasename is a string") + .to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: entry["sourceVersion"].as_str().map(str::to_owned), + collected_at_utc: entry["capturedUtc"].as_str().map(str::to_owned), + rotation, + coverage, + encoding: entry["encoding"].as_str().map(str::to_owned), + } +} + +fn load_bundle(scenario: &str) -> SccmNormalizedBundle { + let scenario_root = deployment_root().join(scenario); + let manifest = load_json(&scenario_root.join("manifest.json")); + let mut artifacts = Vec::new(); + let mut evidence: Vec = Vec::new(); + + for entry in manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + { + let artifact = artifact_from_manifest(entry); + if let Some(relative_path) = entry["relativePath"].as_str() { + let content = std::fs::read_to_string(scenario_root.join(relative_path)) + .expect("declared evidence is readable UTF-8"); + evidence.extend(normalize_ccm_artifact(artifact.clone(), &content)); + } + artifacts.push(artifact); + } + + SccmNormalizedBundle { + artifacts, + evidence, + } +} + +fn phase_name(phase: SccmDeploymentPhase) -> &'static str { + match phase { + SccmDeploymentPhase::Intent => "intent", + SccmDeploymentPhase::Requirements => "requirements", + SccmDeploymentPhase::LocateContent => "locateContent", + SccmDeploymentPhase::Transfer => "transfer", + SccmDeploymentPhase::Cache => "cache", + SccmDeploymentPhase::Enforce => "enforce", + SccmDeploymentPhase::Detect => "detect", + SccmDeploymentPhase::Report => "report", + } +} + +fn state_name(state: SccmDeploymentState) -> &'static str { + match state { + SccmDeploymentState::NotTargeted => "notTargeted", + SccmDeploymentState::InsufficientEvidence => "insufficientEvidence", + SccmDeploymentState::Failed => "failed", + SccmDeploymentState::DetectionMismatch => "detectionMismatch", + SccmDeploymentState::Succeeded => "succeeded", + } +} + +fn classification_name(classification: SccmDeploymentClassification) -> &'static str { + match classification { + SccmDeploymentClassification::NotTargeted => "notTargeted", + SccmDeploymentClassification::InsufficientEvidence => "insufficientEvidence", + SccmDeploymentClassification::Symptom => "symptom", + SccmDeploymentClassification::ConfirmedFailure => "confirmedFailure", + SccmDeploymentClassification::Success => "success", + } +} + +fn confidence_name(confidence: SccmDeploymentConfidence) -> &'static str { + match confidence { + SccmDeploymentConfidence::Low => "low", + SccmDeploymentConfidence::Medium => "medium", + SccmDeploymentConfidence::High => "high", + } +} + +fn key_profile_name(kind: SccmDeploymentKeyProfileKind) -> &'static str { + match kind { + SccmDeploymentKeyProfileKind::AssignmentCi => "assignmentCi", + SccmDeploymentKeyProfileKind::AssignmentCiContentTopology => "assignmentCiContentTopology", + } +} + +fn key_confidence_name(confidence: SccmDeploymentKeyConfidence) -> &'static str { + match confidence { + SccmDeploymentKeyConfidence::Candidate => "candidate", + SccmDeploymentKeyConfidence::Exact => "exact", + } +} + +#[test] +fn declared_transaction_outcomes_are_reproduced_for_every_scenario() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = expected["transactions"] + .as_array() + .expect("declared transactions are an array"); + + assert_eq!( + analysis.transactions.len(), + declared.len(), + "{scenario}: transaction count" + ); + + for (produced, declared) in analysis.transactions.iter().zip(declared) { + let label = format!("{scenario}/{}", produced.transaction_id); + assert_eq!( + produced.transaction_id, declared["transactionId"], + "{label}: transaction id" + ); + assert_eq!( + phase_name(produced.phase), + declared["phase"].as_str().expect("declared phase"), + "{label}: phase" + ); + assert_eq!( + state_name(produced.state), + declared["state"].as_str().expect("declared state"), + "{label}: state" + ); + assert_eq!( + produced.last_successful_phase.map(phase_name), + declared["lastSuccessfulPhase"].as_str(), + "{label}: last successful phase" + ); + assert_eq!( + classification_name(produced.classification), + declared["classification"] + .as_str() + .expect("declared classification"), + "{label}: classification" + ); + assert_eq!( + confidence_name(produced.confidence), + declared["confidence"].as_str().expect("declared confidence"), + "{label}: confidence" + ); + assert_eq!( + confidence_name(produced.confidence_ceiling), + declared["confidenceCeiling"] + .as_str() + .expect("declared confidence ceiling"), + "{label}: confidence ceiling" + ); + } + } +} + +#[test] +fn declared_transaction_keys_are_bound_to_the_selected_version_profile() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = expected["transactions"] + .as_array() + .expect("declared transactions are an array"); + + for (produced, declared) in analysis.transactions.iter().zip(declared) { + let label = format!("{scenario}/{}", produced.transaction_id); + let key = &produced.key; + let declared_key = &declared["key"]; + + assert_eq!( + key_profile_name(key.key_profile_kind), + declared_key["keyProfileKind"] + .as_str() + .expect("declared key profile kind"), + "{label}: key profile kind" + ); + assert_eq!( + key_confidence_name(key.confidence), + declared_key["confidence"] + .as_str() + .expect("declared key confidence"), + "{label}: key confidence" + ); + assert_eq!( + key.extraction_profile_id, SCCM_DEPLOYMENT_TEST_PROFILE_ID, + "{label}: extraction profile" + ); + assert_eq!( + declared_key["extractionProfileId"], SCCM_DEPLOYMENT_TEST_PROFILE_ID, + "{label}: declared extraction profile" + ); + + assert_eq!( + Some(key.assignment_id.as_str()), + declared_key["assignmentId"].as_str(), + "{label}: assignmentId" + ); + assert_eq!( + Some(key.ci_id.as_str()), + declared_key["ciId"].as_str(), + "{label}: ciId" + ); + assert_eq!( + key.package_id.as_deref(), + declared_key["packageId"].as_str(), + "{label}: packageId" + ); + assert_eq!( + key.content_id.as_deref(), + declared_key["contentId"].as_str(), + "{label}: contentId" + ); + assert_eq!( + key.content_version.map(u64::from), + declared_key["contentVersion"].as_u64(), + "{label}: contentVersion" + ); + assert_eq!( + key.distribution_point_host_handle.as_deref(), + declared_key["distributionPointHostHandle"].as_str(), + "{label}: distributionPointHostHandle" + ); + assert_eq!( + key.request_id.as_deref(), + declared_key["requestId"].as_str(), + "{label}: requestId" + ); + assert_eq!( + key.bits_job_id.as_deref(), + declared_key["bitsJobId"].as_str(), + "{label}: bitsJobId" + ); + assert_eq!( + key.product_code.as_deref(), + declared_key["productCode"].as_str(), + "{label}: productCode" + ); + assert_eq!( + key.exit_code.as_deref(), + declared_key["exitCode"].as_str(), + "{label}: exitCode" + ); + } + } +} + +#[test] +fn declared_transaction_evidence_spans_are_reproduced_exactly() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = expected["transactions"] + .as_array() + .expect("declared transactions are an array"); + + for (produced, declared) in analysis.transactions.iter().zip(declared) { + let label = format!("{scenario}/{}", produced.transaction_id); + let produced_spans = produced + .evidence + .iter() + .map(|reference| { + ( + reference.artifact_id.clone(), + reference.line_start, + reference.line_end, + ) + }) + .collect::>(); + let declared_spans = declared["evidence"] + .as_array() + .expect("declared evidence is an array") + .iter() + .map(|reference| { + ( + reference["artifactId"] + .as_str() + .expect("declared artifactId") + .to_owned(), + reference["startLine"].as_u64().map(|line| line as u32), + reference["endLine"].as_u64().map(|line| line as u32), + ) + }) + .collect::>(); + assert_eq!(produced_spans, declared_spans, "{label}: evidence spans"); + } + } +} + +#[test] +fn counterpart_ready_facts_match_the_declared_content_request_boundary() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = expected["transactions"] + .as_array() + .expect("declared transactions are an array"); + + for (produced, declared) in analysis.transactions.iter().zip(declared) { + let label = format!("{scenario}/{}", produced.transaction_id); + let declared_fact = &declared["counterpartReadyFact"]; + let Some(fact) = produced.counterpart_ready_fact.as_ref() else { + assert!( + declared_fact.is_null(), + "{label}: missing declared counterpart-ready fact" + ); + continue; + }; + assert!( + !declared_fact.is_null(), + "{label}: unexpected counterpart-ready fact" + ); + + assert_eq!( + phase_name(fact.phase), + declared_fact["phase"].as_str().expect("declared fact phase"), + "{label}: counterpart phase" + ); + assert_eq!( + fact.extraction_profile_id, SCCM_DEPLOYMENT_TEST_PROFILE_ID, + "{label}: counterpart profile" + ); + assert_eq!( + Some(fact.package_id.as_str()), + declared_fact["packageId"].as_str(), + "{label}: counterpart packageId" + ); + assert_eq!( + Some(fact.content_id.as_str()), + declared_fact["contentId"].as_str(), + "{label}: counterpart contentId" + ); + assert_eq!( + Some(u64::from(fact.content_version)), + declared_fact["contentVersion"].as_u64(), + "{label}: counterpart contentVersion" + ); + assert_eq!( + Some(fact.distribution_point_host_handle.as_str()), + declared_fact["distributionPointHostHandle"].as_str(), + "{label}: counterpart distributionPointHostHandle" + ); + assert_eq!( + Some(fact.request_id.as_str()), + declared_fact["requestId"].as_str(), + "{label}: counterpart requestId" + ); + assert_eq!( + Some(fact.timestamp_provenance.normalized_utc.as_str()), + declared_fact["timestampProvenance"]["normalizedUtc"].as_str(), + "{label}: counterpart normalized UTC" + ); + assert_eq!( + Some(i64::from(fact.timestamp_provenance.offset_minutes)), + declared_fact["timestampProvenance"]["offsetMinutes"].as_i64(), + "{label}: counterpart offset" + ); + assert_eq!( + Some(fact.evidence.artifact_id.as_str()), + declared_fact["evidence"]["artifactId"].as_str(), + "{label}: counterpart evidence artifact" + ); + assert_eq!( + fact.evidence.line_start.map(u64::from), + declared_fact["evidence"]["startLine"].as_u64(), + "{label}: counterpart evidence start" + ); + assert_eq!( + fact.evidence.line_end.map(u64::from), + declared_fact["evidence"]["endLine"].as_u64(), + "{label}: counterpart evidence end" + ); + } + } +} + +#[test] +fn no_scenario_claims_a_distribution_point_or_server_cause() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let handoff = &analysis.correlation_handoff; + assert!(!handoff.performed, "{scenario}: #333 is not performed here"); + assert!( + !handoff.time_only_eligible, + "{scenario}: time alone cannot correlate" + ); + assert!( + !handoff.topology_compatibility_evaluated, + "{scenario}: topology belongs to #333" + ); + assert!( + !handoff.server_cause_claimed, + "{scenario}: no DP or server cause" + ); + assert_eq!( + handoff.emitted_counterpart_ready_fact, + analysis + .transactions + .iter() + .any(|transaction| transaction.counterpart_ready_fact.is_some()), + "{scenario}: counterpart handoff flag" + ); + } +} From 0715afcf90792b44dcd7c4d8a72c608477ef31b4 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:34:14 -0400 Subject: [PATCH 361/422] feat(sccm): reduce client deployment transactions Refs #322 --- crates/cmtraceopen-parser/src/sccm/catalog.rs | 6 + .../src/sccm/client/deployment.rs | 1453 +++++++++++++++++ .../cmtraceopen-parser/src/sccm/client/mod.rs | 14 + .../cmtraceopen-parser/src/sccm/findings.rs | 13 +- crates/cmtraceopen-parser/src/sccm/models.rs | 2 + .../src/sccm/server/windows/intake.rs | 1 + .../tests/sccm_client_deployment.rs | 8 +- .../tests/sccm_spine_contract.rs | 8 + 8 files changed, 1497 insertions(+), 8 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/client/deployment.rs diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs index 21a4f990c..f28c4ffcc 100644 --- a/crates/cmtraceopen-parser/src/sccm/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -400,6 +400,12 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ role: SccmRole::Client, family: SccmArtifactFamily::ClientPolicy, }, + CatalogSpec { + basename: "StateMessage", + logical_name: "stateMessage", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientPolicy, + }, CatalogSpec { basename: "CAS", logical_name: "cas", diff --git a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs new file mode 100644 index 000000000..a87962fc8 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs @@ -0,0 +1,1453 @@ +//! Issue #322: application, package, and content deployment transactions. +//! +//! The reducer is pure. It turns a normalized client bundle into conservative +//! transactions whose every claim cites complete logical records. It never +//! reads the file system, never contacts a server, and never states a +//! distribution-point or site-server cause: the only cross-side output is a +//! counterpart-ready client content request that issue #333 may later match. +//! +//! Deployment state chain: +//! +//! ```text +//! Intent -> Requirements -> LocateContent -> Transfer -> Cache -> Enforce -> Detect -> Report +//! ``` + +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; + +use crate::sccm::{ + classify_artifact_name, SccmArtifact, SccmCoverageState, SccmEvidence, SccmEvidenceRef, + SccmRole, SccmTimeOrderingState, +}; + +use super::SccmNormalizedBundle; + +pub const SCCM_DEPLOYMENT_ANALYSIS_SCHEMA_VERSION: u32 = 1; +pub const SCCM_DEPLOYMENT_TEST_PROFILE_ID: &str = "deployment-client-5.00.test-v1"; +pub const SCCM_DEPLOYMENT_TEST_VERSION_PREFIX: &str = "5.00.TEST."; + +const GROUP_APP_INTENT: &str = "client-app-intent"; +const GROUP_APP_ENFORCE: &str = "client-app-enforce"; +const GROUP_CONTENT: &str = "client-content"; +const GROUP_POLICY_STATE: &str = "client-policy-state"; + +const REASON_LOCATION_RESPONSE_MISSING: &str = + "capture a complete terminal location response; a client request alone cannot prove DP content state"; +const REASON_LOCATION_ACCESS_DENIED: &str = + "access denied is a coverage state, not proof of content success or failure"; +const REASON_LOCATION_ROTATION: &str = + "capture a complete logical CCM content record without joining physical rotation fragments"; +const REASON_LOCATION_ABSENT: &str = + "Capture an exact client content-location response for this assignment and CI."; +const REASON_INTENT: &str = + "capture the complete client application intent record for this assignment and CI"; +const REASON_REQUIREMENTS: &str = + "capture the complete client requirement and dependency outcome for this assignment and CI"; +const REASON_TRANSFER: &str = "capture the complete client content transfer outcome for this key"; +const REASON_CACHE: &str = "capture the complete client cache commit outcome for this key"; +const REASON_ENFORCE: &str = "capture the complete client enforcement outcome for this key"; +const REASON_DETECT: &str = "capture the complete client detection outcome for this key"; +const REASON_REPORT: &str = "capture the complete client deployment state report for this key"; +const REASON_CHRONOLOGY: &str = + "capture records whose timestamps can be ordered against the earlier phases of this key"; + +const COUNTERPART_READY_KEY_KINDS: [&str; 5] = [ + "contentId", + "contentVersion", + "distributionPointHostHandle", + "packageId", + "requestId", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentWorkflow { + Deployment, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentPhase { + Intent, + Requirements, + LocateContent, + Transfer, + Cache, + Enforce, + Detect, + Report, +} + +impl SccmDeploymentPhase { + pub fn as_str(self) -> &'static str { + match self { + Self::Intent => "intent", + Self::Requirements => "requirements", + Self::LocateContent => "locateContent", + Self::Transfer => "transfer", + Self::Cache => "cache", + Self::Enforce => "enforce", + Self::Detect => "detect", + Self::Report => "report", + } + } + + fn artifact_group(self) -> &'static str { + match self { + Self::Intent | Self::Requirements | Self::Detect => GROUP_APP_INTENT, + Self::LocateContent | Self::Transfer | Self::Cache => GROUP_CONTENT, + Self::Enforce => GROUP_APP_ENFORCE, + Self::Report => GROUP_POLICY_STATE, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentState { + NotTargeted, + InsufficientEvidence, + Failed, + DetectionMismatch, + Succeeded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentClassification { + NotTargeted, + InsufficientEvidence, + Symptom, + ConfirmedFailure, + Success, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentConfidence { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentKeyProfileKind { + AssignmentCi, + AssignmentCiContentTopology, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentKeyConfidence { + Candidate, + Exact, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentTimestampProvenanceKind { + ExplicitOffset, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentCounterpartFactKind { + ClientContentRequest, +} + +/// Exact, version-profiled identity of one deployment transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentKey { + pub key_profile_kind: SccmDeploymentKeyProfileKind, + pub assignment_id: String, + pub ci_id: String, + pub package_id: Option, + pub content_id: Option, + pub content_version: Option, + pub distribution_point_host_handle: Option, + pub request_id: Option, + pub bits_job_id: Option, + pub product_code: Option, + pub exit_code: Option, + pub confidence: SccmDeploymentKeyConfidence, + pub extraction_profile_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentTimestampProvenance { + pub kind: SccmDeploymentTimestampProvenanceKind, + pub offset_minutes: i32, + pub normalized_utc: String, +} + +/// The only issue #333 handoff this reducer produces. +/// +/// It restates an exact client-side content request. It is not a distribution +/// point observation and carries no claim about a server outcome. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentCounterpartFact { + pub fact_kind: SccmDeploymentCounterpartFactKind, + pub phase: SccmDeploymentPhase, + pub extraction_profile_id: String, + pub package_id: String, + pub content_id: String, + pub content_version: u32, + pub distribution_point_host_handle: String, + pub request_id: String, + pub timestamp_provenance: SccmDeploymentTimestampProvenance, + pub evidence: SccmEvidenceRef, +} + +/// The smallest next evidence bundle, named by deployment artifact group. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentArtifactRequest { + pub logical_artifact_id: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentTransaction { + pub transaction_id: String, + pub key: SccmDeploymentKey, + pub counterpart_ready_fact: Option, + pub phase: SccmDeploymentPhase, + pub state: SccmDeploymentState, + pub last_successful_phase: Option, + pub classification: SccmDeploymentClassification, + pub confidence: SccmDeploymentConfidence, + pub confidence_ceiling: SccmDeploymentConfidence, + pub coverage_gap_artifact_ids: Vec, + pub next_artifact: Option, + pub evidence: Vec, +} + +/// Coverage of one deployment artifact group. Absence is a state, never proof. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentCoverage { + pub logical_artifact_id: String, + pub state: SccmCoverageState, + pub artifact_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentExtractionProfile { + 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, 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 correlation_handoff: SccmDeploymentCorrelationHandoff, +} + +/// Reduce a normalized client bundle into deployment transactions. +pub fn analyze_client_deployment(bundle: &SccmNormalizedBundle) -> SccmDeploymentAnalysis { + let coverage = coverage_rows(bundle); + + if bundle_identity_collides(bundle) { + return finalize(coverage, Vec::new()); + } + + // Only client-role artifacts may participate. Building this map from the + // full artifact list would let another role's identical artifact ID decide + // which source a record came from. + let artifacts_by_id = bundle + .artifacts + .iter() + .filter(|artifact| artifact.role == SccmRole::Client) + .map(|artifact| (artifact.artifact_id.as_str(), artifact)) + .collect::>(); + + let mut facts = bundle + .evidence + .iter() + .filter(|evidence| evidence.role == SccmRole::Client) + .flat_map(|evidence| { + artifacts_by_id + .get(evidence.reference.artifact_id.as_str()) + .map(|artifact| parse_deployment_facts(evidence, artifact)) + .unwrap_or_default() + }) + .collect::>(); + facts.sort_by(|left, right| compare_references(&left.reference, &right.reference)); + + let mut by_assignment = BTreeMap::<&str, Vec<&DeploymentFact>>::new(); + for fact in &facts { + by_assignment + .entry(fact.assignment_id.as_str()) + .or_default() + .push(fact); + } + + let transactions = by_assignment + .into_iter() + .filter_map(|(assignment_id, facts)| build_transaction(assignment_id, &facts, &coverage)) + .collect::>(); + + finalize(coverage, transactions) +} + +fn finalize( + coverage: Vec, + transactions: Vec, +) -> SccmDeploymentAnalysis { + let emitted_counterpart_ready_fact = transactions + .iter() + .any(|transaction| transaction.counterpart_ready_fact.is_some()); + + SccmDeploymentAnalysis { + schema_version: SCCM_DEPLOYMENT_ANALYSIS_SCHEMA_VERSION, + workflow: SccmDeploymentWorkflow::Deployment, + extraction_profile: extraction_profile(&coverage, &transactions), + coverage, + transactions, + 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( + coverage: &[SccmDeploymentCoverage], + 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); + } + } + } + + let validated_artifact_families = coverage + .iter() + .filter(|row| { + row.state == SccmCoverageState::Captured + && matches!( + row.logical_artifact_id.as_str(), + GROUP_APP_INTENT | GROUP_APP_ENFORCE | GROUP_CONTENT | GROUP_POLICY_STATE + ) + }) + .map(|row| row.logical_artifact_id.clone()) + .collect::>(); + + SccmDeploymentExtractionProfile { + profile_id: SCCM_DEPLOYMENT_TEST_PROFILE_ID.to_owned(), + source_version_prefix: SCCM_DEPLOYMENT_TEST_VERSION_PREFIX.to_owned(), + content_version_required: true, + key_kinds: key_kinds.into_iter().map(str::to_owned).collect(), + validated_artifact_families, + } +} + +// --------------------------------------------------------------------------- +// Identity guards +// --------------------------------------------------------------------------- + +/// Duplicate artifact or evidence identities make source authority ambiguous. +/// The reducer then reports coverage only: vector order must never elect one. +fn bundle_identity_collides(bundle: &SccmNormalizedBundle) -> bool { + let mut artifact_ids = BTreeSet::new(); + for artifact in bundle + .artifacts + .iter() + .filter(|artifact| artifact.role == SccmRole::Client) + { + if !artifact_ids.insert(artifact.artifact_id.as_str()) { + return true; + } + } + + let mut evidence_ids = BTreeSet::new(); + let mut references = BTreeSet::new(); + for evidence in bundle + .evidence + .iter() + .filter(|evidence| evidence.role == SccmRole::Client) + { + if !evidence_ids.insert(evidence.evidence_id.as_str()) { + return true; + } + if !references.insert(( + evidence.reference.artifact_id.as_str(), + evidence.reference.entry_id.as_str(), + evidence.reference.line_start, + evidence.reference.line_end, + )) { + return true; + } + } + + false +} + +// --------------------------------------------------------------------------- +// Coverage +// --------------------------------------------------------------------------- + +fn coverage_rows(bundle: &SccmNormalizedBundle) -> Vec { + let mut grouped = BTreeMap::>::new(); + for artifact in bundle + .artifacts + .iter() + .filter(|artifact| artifact.role == SccmRole::Client) + { + grouped + .entry(deployment_group_id(&artifact.display_name)) + .or_default() + .push(artifact); + } + + grouped + .into_iter() + .map(|(logical_artifact_id, artifacts)| { + let states = artifacts + .iter() + .map(|artifact| artifact.coverage.clone()) + .collect::>(); + let mut artifact_ids = artifacts + .iter() + .filter(|artifact| artifact.coverage != SccmCoverageState::Captured) + .map(|artifact| artifact.artifact_id.clone()) + .collect::>(); + artifact_ids.sort(); + artifact_ids.dedup(); + + SccmDeploymentCoverage { + logical_artifact_id, + state: combine_coverage(&states), + artifact_ids, + } + }) + .collect() +} + +/// Any complete capture makes the group usable; otherwise the most explanatory +/// incomplete state wins. Conflicting noncapture states stay `ParseFailed` so +/// no caller can read a single cause out of a mixed group. +fn combine_coverage(states: &[SccmCoverageState]) -> SccmCoverageState { + for candidate in [ + SccmCoverageState::Captured, + SccmCoverageState::Capped, + SccmCoverageState::Partial, + ] { + if states.contains(&candidate) { + return candidate; + } + } + + let distinct = states + .iter() + .map(coverage_order) + .collect::>() + .len(); + match (distinct, states.first()) { + (1, Some(state)) => state.clone(), + _ => SccmCoverageState::ParseFailed, + } +} + +fn coverage_order(coverage: &SccmCoverageState) -> u8 { + match coverage { + SccmCoverageState::Captured => 0, + SccmCoverageState::Partial => 1, + SccmCoverageState::Absent => 2, + SccmCoverageState::AccessDenied => 3, + SccmCoverageState::Capped => 4, + SccmCoverageState::Skipped => 5, + SccmCoverageState::Unsupported => 6, + SccmCoverageState::ParseFailed => 7, + } +} + +fn coverage_for_group<'a>( + coverage: &'a [SccmDeploymentCoverage], + group: &str, +) -> Option<&'a SccmDeploymentCoverage> { + coverage.iter().find(|row| row.logical_artifact_id == group) +} + +// --------------------------------------------------------------------------- +// Source classification +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeploymentSourceKind { + Intent, + Discovery, + Enforce, + ContentAccess, + Transfer, + StateReport, +} + +#[derive(Debug, Clone, Copy)] +struct DeploymentSource { + group: &'static str, + kind: DeploymentSourceKind, +} + +/// Exact logical-name table. An unlisted source is never a deployment fact +/// source, no matter how similar its text looks. +fn deployment_source(logical_name: &str) -> Option { + let (group, kind) = match logical_name { + "appIntentEval" => (GROUP_APP_INTENT, DeploymentSourceKind::Intent), + "appDiscovery" => (GROUP_APP_INTENT, DeploymentSourceKind::Discovery), + "appEnforce" => (GROUP_APP_ENFORCE, DeploymentSourceKind::Enforce), + "cas" => (GROUP_CONTENT, DeploymentSourceKind::ContentAccess), + "dataTransferService" | "contentTransferManager" => { + (GROUP_CONTENT, DeploymentSourceKind::Transfer) + } + "stateMessage" => (GROUP_POLICY_STATE, DeploymentSourceKind::StateReport), + _ => return None, + }; + Some(DeploymentSource { group, kind }) +} + +fn deployment_group_id(display_name: &str) -> String { + let catalog = classify_artifact_name(display_name, SccmRole::Client); + match deployment_source(&catalog.logical_name) { + Some(source) => source.group.to_owned(), + None => format!("client-{}", kebab_case(&catalog.logical_name)), + } +} + +fn kebab_case(value: &str) -> String { + let mut result = String::with_capacity(value.len() + 4); + for character in value.chars() { + if character.is_ascii_uppercase() { + if !result.is_empty() { + result.push('-'); + } + result.push(character.to_ascii_lowercase()); + } else { + result.push(character); + } + } + result +} + +// --------------------------------------------------------------------------- +// Facts +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeploymentFactKind { + IntentTargeted, + IntentNotApplicable, + RequirementsSatisfied, + RequirementsFailed, + DependencyFailed, + ContentLocated, + ContentRequested, + TransferStarted, + TransferCompleted, + TransferFailed, + CacheCommitted, + CacheFailed, + EnforceSucceeded, + EnforceFailed, + Detected, + DetectionMismatch, + ReportSucceeded, + ReportFailed, +} + +#[derive(Debug, Clone)] +struct DeploymentFact { + kind: DeploymentFactKind, + reference: SccmEvidenceRef, + utc_millis: Option, + offset_minutes: Option, + time_comparable: bool, + assignment_id: String, + ci_id: Option, + package_id: Option, + content_id: Option, + content_version: Option, + distribution_point_host_handle: Option, + request_id: Option, + bits_job_id: Option, + product_code: Option, + exit_code: Option, +} + +fn parse_deployment_facts(evidence: &SccmEvidence, artifact: &SccmArtifact) -> Vec { + let Some(source) = admitted_source(evidence, artifact) else { + return Vec::new(); + }; + let Some(payload) = deployment_event_payload(&evidence.message) else { + return Vec::new(); + }; + let phrase = event_phrase(payload); + let Some(assignment_id) = + field_value(payload, "assignmentId").filter(|value| valid_guid(value)) + else { + return Vec::new(); + }; + + let kinds = match source.kind { + DeploymentSourceKind::Intent => intent_fact_kinds(&phrase, payload), + DeploymentSourceKind::Discovery => discovery_fact_kinds(&phrase, payload), + DeploymentSourceKind::Enforce => enforce_fact_kinds(&phrase, payload), + DeploymentSourceKind::ContentAccess => content_fact_kinds(&phrase, payload), + DeploymentSourceKind::Transfer => transfer_fact_kinds(&phrase, payload), + DeploymentSourceKind::StateReport => report_fact_kinds(&phrase, payload), + }; + if kinds.is_empty() { + return Vec::new(); + } + + let template = DeploymentFact { + kind: DeploymentFactKind::IntentTargeted, + reference: evidence.reference.clone(), + utc_millis: evidence.timestamp.utc_millis, + offset_minutes: evidence.timestamp.offset_minutes, + time_comparable: evidence.timestamp.ordering_state == SccmTimeOrderingState::NormalizedUtc + && evidence.timestamp.utc_millis.is_some(), + assignment_id: assignment_id.to_owned(), + ci_id: field_value(payload, "ciId") + .filter(|value| valid_guid(value)) + .map(str::to_owned), + package_id: field_value(payload, "packageId") + .filter(|value| valid_package_id(value)) + .map(str::to_owned), + content_id: field_value(payload, "contentId") + .filter(|value| valid_guid(value)) + .map(str::to_owned), + content_version: field_value(payload, "contentVersion").and_then(parse_content_version), + distribution_point_host_handle: field_value(payload, "distributionPointHostHandle") + .filter(|value| valid_safe_handle(value)) + .map(str::to_owned), + request_id: field_value(payload, "requestId") + .filter(|value| valid_guid(value)) + .map(str::to_owned), + bits_job_id: field_value(payload, "bitsJobId") + .filter(|value| valid_guid(value)) + .map(str::to_owned), + product_code: field_value(payload, "productCode") + .filter(|value| valid_guid(value)) + .map(str::to_owned), + exit_code: field_value(payload, "exitCode") + .filter(|value| valid_exit_code(value)) + .map(str::to_owned), + }; + + kinds + .into_iter() + .map(|kind| DeploymentFact { + kind, + ..template.clone() + }) + .collect() +} + +/// Version profile, role, coverage, rotation, and catalog identity must all +/// agree before a record may become a fact. +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; + } + if !artifact + .configmgr_version + .as_deref() + .is_some_and(|version| version.starts_with(SCCM_DEPLOYMENT_TEST_VERSION_PREFIX)) + { + return None; + } + + let catalog = classify_artifact_name(&artifact.display_name, SccmRole::Client); + if !catalog.supported_for_diagnosis || artifact.rotation != catalog.rotation { + return None; + } + deployment_source(&catalog.logical_name) +} + +fn intent_fact_kinds(phrase: &str, payload: &str) -> Vec { + let mut kinds = Vec::new(); + let state = field_value(payload, "state"); + let terminal = is_terminal(payload); + + if matches!(phrase, "intent" | "targeted" | "requirements satisfied") + && state == Some("targeted") + { + kinds.push(DeploymentFactKind::IntentTargeted); + } + if matches!( + phrase, + "explicitly not targeted" | "not targeted" | "not applicable" + ) && state == Some("notApplicable") + && terminal + { + kinds.push(DeploymentFactKind::IntentNotApplicable); + } + if phrase == "requirements satisfied" { + kinds.push(DeploymentFactKind::RequirementsSatisfied); + } + if phrase == "requirements terminal failure" + && terminal + && field_value(payload, "requirementId").is_some_and(valid_safe_key) + { + kinds.push(DeploymentFactKind::RequirementsFailed); + } + if phrase == "dependency terminal failure" + && terminal + && field_value(payload, "dependencyCiId").is_some_and(valid_guid) + { + kinds.push(DeploymentFactKind::DependencyFailed); + } + kinds +} + +fn discovery_fact_kinds(phrase: &str, payload: &str) -> Vec { + match (phrase, field_value(payload, "detected")) { + ("detected", Some("true")) => vec![DeploymentFactKind::Detected], + ("detection false negative", Some("false")) => vec![DeploymentFactKind::DetectionMismatch], + _ => Vec::new(), + } +} + +/// A nonzero exit code alone stays a symptom. A confirmed enforcement failure +/// needs the terminal marker on the same complete record. +fn enforce_fact_kinds(phrase: &str, payload: &str) -> Vec { + let Some(exit_code) = field_value(payload, "exitCode").filter(|value| valid_exit_code(value)) + else { + return Vec::new(); + }; + if !is_terminal(payload) { + return Vec::new(); + } + match phrase { + "enforcement completed" if exit_code == "0" => vec![DeploymentFactKind::EnforceSucceeded], + "enforcement terminal failure" if exit_code != "0" => { + vec![DeploymentFactKind::EnforceFailed] + } + _ => Vec::new(), + } +} + +fn content_fact_kinds(phrase: &str, payload: &str) -> Vec { + let has_topology = field_value(payload, "packageId").is_some_and(valid_package_id) + && field_value(payload, "contentId").is_some_and(valid_guid) + && field_value(payload, "contentVersion") + .and_then(parse_content_version) + .is_some() + && field_value(payload, "distributionPointHostHandle").is_some_and(valid_safe_handle) + && field_value(payload, "requestId").is_some_and(valid_guid); + + match phrase { + "content located" if has_topology => vec![DeploymentFactKind::ContentLocated], + "content request observed" + if has_topology && field_value(payload, "responseState") == Some("unknown") => + { + vec![DeploymentFactKind::ContentRequested] + } + "cache commit completed" + if field_value(payload, "contentId").is_some_and(valid_guid) + && field_value(payload, "contentVersion") + .and_then(parse_content_version) + .is_some() => + { + vec![DeploymentFactKind::CacheCommitted] + } + "cache commit terminal failure" if is_terminal(payload) && has_nonzero_error(payload) => { + vec![DeploymentFactKind::CacheFailed] + } + _ => Vec::new(), + } +} + +fn transfer_fact_kinds(phrase: &str, payload: &str) -> Vec { + let content_id = field_value(payload, "contentId").is_some_and(valid_guid); + let bits_job_id = field_value(payload, "bitsJobId").is_some_and(valid_guid); + match phrase { + "transfer started" + if content_id + && bits_job_id + && field_value(payload, "requestId").is_some_and(valid_guid) => + { + vec![DeploymentFactKind::TransferStarted] + } + "transfer completed" if content_id && bits_job_id => { + vec![DeploymentFactKind::TransferCompleted] + } + "transfer terminal failure" + if content_id && bits_job_id && is_terminal(payload) && has_nonzero_error(payload) => + { + vec![DeploymentFactKind::TransferFailed] + } + _ => Vec::new(), + } +} + +fn report_fact_kinds(phrase: &str, payload: &str) -> Vec { + match (phrase, field_value(payload, "state")) { + ("reported", Some("succeeded")) => vec![DeploymentFactKind::ReportSucceeded], + ("reported", Some("failed")) => vec![DeploymentFactKind::ReportFailed], + _ => Vec::new(), + } +} + +// --------------------------------------------------------------------------- +// Message grammar +// --------------------------------------------------------------------------- + +const PUBLIC_MESSAGE_PREFIX: &str = "[sccm-public-message-v1] "; +const EVENT_QUALIFIERS: [&str; 4] = ["SYNTHETIC", "FIXTURE", "deployment", "success"]; + +fn deployment_event_payload(message: &str) -> Option<&str> { + message.strip_prefix(PUBLIC_MESSAGE_PREFIX) +} + +/// The leading clause of a record, with documented capture and scope +/// qualifiers removed. Only a phrase that starts the clause selects an event, +/// so an embedded label such as `Base requirements satisfied` never matches +/// `requirements satisfied`. +fn event_phrase(payload: &str) -> String { + payload + .split_whitespace() + .take_while(|word| !word.contains('=')) + .skip_while(|word| EVENT_QUALIFIERS.contains(word)) + .collect::>() + .join(" ") + .to_ascii_lowercase() +} + +/// Exact-token lookup. A duplicated label, or a label that is only the tail of +/// a longer token, yields nothing rather than a guess. +fn field_value<'a>(message: &'a str, key: &str) -> Option<&'a str> { + let marker = format!("{key}="); + let mut matches = message + .match_indices(&marker) + .filter(|(index, _)| *index == 0 || message.as_bytes()[index - 1].is_ascii_whitespace()); + let (first, _) = matches.next()?; + if matches.next().is_some() { + 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, +} + +fn build_transaction( + assignment_id: &str, + facts: &[&DeploymentFact], + coverage: &[SccmDeploymentCoverage], +) -> 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); + + Some(SccmDeploymentTransaction { + transaction_id: format!("deployment:assignment:{assignment_id}"), + counterpart_ready_fact: counterpart_ready_fact(facts), + 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: merged_evidence(facts), + }) +} + +/// Distinct values collapse to one key only when they agree. Two different +/// values are ambiguity, not a choice. +fn unique_value(values: impl Iterator) -> Option { + let mut distinct = values.collect::>(); + (distinct.len() == 1) + .then(|| distinct.pop_first()) + .flatten() +} + +fn build_key(assignment_id: &str, ci_id: &str, facts: &[&DeploymentFact]) -> SccmDeploymentKey { + let package_id = unique_value(facts.iter().filter_map(|fact| fact.package_id.clone())); + let content_id = unique_value(facts.iter().filter_map(|fact| fact.content_id.clone())); + let content_version = unique_value(facts.iter().filter_map(|fact| fact.content_version)); + let distribution_point_host_handle = unique_value( + facts + .iter() + .filter_map(|fact| fact.distribution_point_host_handle.clone()), + ); + let request_id = unique_value(facts.iter().filter_map(|fact| fact.request_id.clone())); + let has_topology = package_id.is_some() + && content_id.is_some() + && content_version.is_some() + && distribution_point_host_handle.is_some() + && request_id.is_some(); + + SccmDeploymentKey { + key_profile_kind: if has_topology { + SccmDeploymentKeyProfileKind::AssignmentCiContentTopology + } else { + SccmDeploymentKeyProfileKind::AssignmentCi + }, + assignment_id: assignment_id.to_owned(), + ci_id: ci_id.to_owned(), + package_id, + content_id, + content_version, + distribution_point_host_handle, + request_id, + bits_job_id: unique_value(facts.iter().filter_map(|fact| fact.bits_job_id.clone())), + product_code: unique_value(facts.iter().filter_map(|fact| fact.product_code.clone())), + exit_code: unique_value( + facts + .iter() + .filter(|fact| { + matches!( + fact.kind, + DeploymentFactKind::EnforceSucceeded | DeploymentFactKind::EnforceFailed + ) + }) + .filter_map(|fact| fact.exit_code.clone()), + ), + confidence: SccmDeploymentKeyConfidence::Exact, + extraction_profile_id: SCCM_DEPLOYMENT_TEST_PROFILE_ID.to_owned(), + } +} + +/// One reference per physical artifact, spanning the first through the last +/// cited record. Callers can reopen exactly the bytes that produced the claim. +fn merged_evidence(facts: &[&DeploymentFact]) -> Vec { + let mut spans = BTreeMap::<&str, (u32, u32)>::new(); + for fact in facts { + let (Some(start), Some(end)) = (fact.reference.line_start, fact.reference.line_end) else { + continue; + }; + spans + .entry(fact.reference.artifact_id.as_str()) + .and_modify(|span| { + span.0 = span.0.min(start); + span.1 = span.1.max(end); + }) + .or_insert((start, end)); + } + + spans + .into_iter() + .map(|(artifact_id, (start, end))| SccmEvidenceRef { + artifact_id: artifact_id.to_owned(), + entry_id: format!("{artifact_id}:{start}-{end}"), + line_start: Some(start), + line_end: Some(end), + }) + .collect() +} + +fn first_fact<'a>( + facts: &[&'a DeploymentFact], + kind: DeploymentFactKind, +) -> Option<&'a DeploymentFact> { + facts.iter().copied().find(|fact| fact.kind == kind) +} + +/// A failure is only unrecovered when no later success of the same phase can be +/// ordered strictly after it. +fn unrecovered_failure<'a>( + facts: &[&'a DeploymentFact], + failure_kind: DeploymentFactKind, + success_kind: DeploymentFactKind, +) -> Option<&'a DeploymentFact> { + facts + .iter() + .copied() + .filter(|fact| fact.kind == failure_kind) + .find(|failure| { + !facts.iter().any(|candidate| { + candidate.kind == success_kind && fact_is_strictly_before(failure, candidate) + }) + }) +} + +fn counterpart_ready_fact(facts: &[&DeploymentFact]) -> Option { + let fact = first_fact(facts, DeploymentFactKind::ContentLocated) + .or_else(|| first_fact(facts, DeploymentFactKind::ContentRequested))?; + let offset_minutes = fact.offset_minutes?; + if !fact.time_comparable { + return None; + } + + Some(SccmDeploymentCounterpartFact { + fact_kind: SccmDeploymentCounterpartFactKind::ClientContentRequest, + phase: SccmDeploymentPhase::LocateContent, + extraction_profile_id: SCCM_DEPLOYMENT_TEST_PROFILE_ID.to_owned(), + package_id: fact.package_id.clone()?, + content_id: fact.content_id.clone()?, + content_version: fact.content_version?, + distribution_point_host_handle: fact.distribution_point_host_handle.clone()?, + request_id: fact.request_id.clone()?, + timestamp_provenance: SccmDeploymentTimestampProvenance { + kind: SccmDeploymentTimestampProvenanceKind::ExplicitOffset, + offset_minutes, + normalized_utc: format_normalized_utc(fact.utc_millis?)?, + }, + evidence: fact.reference.clone(), + }) +} + +fn format_normalized_utc(millis: i64) -> Option { + let timestamp = chrono::DateTime::::from_timestamp_millis(millis)?; + Some(if millis.rem_euclid(1_000) == 0 { + timestamp.format("%Y-%m-%dT%H:%M:%SZ").to_string() + } else { + timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() + }) +} + +fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage]) -> Outcome { + let mut chain: Vec<&DeploymentFact> = Vec::new(); + let mut last: Option = None; + + if let Some(not_applicable) = first_fact(facts, DeploymentFactKind::IntentNotApplicable) { + return conclude( + &[not_applicable], + SccmDeploymentPhase::Intent, + SccmDeploymentState::NotTargeted, + SccmDeploymentClassification::NotTargeted, + None, + ); + } + + 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 let Some(failure) = first_fact(facts, DeploymentFactKind::RequirementsFailed) + .or_else(|| first_fact(facts, DeploymentFactKind::DependencyFailed)) + { + chain.push(failure); + return conclude( + &chain, + SccmDeploymentPhase::Requirements, + SccmDeploymentState::Failed, + SccmDeploymentClassification::ConfirmedFailure, + last, + ); + } + let Some(requirements) = first_fact(facts, DeploymentFactKind::RequirementsSatisfied) else { + return insufficient( + SccmDeploymentPhase::Requirements, + last, + REASON_REQUIREMENTS, + coverage, + ); + }; + chain.push(requirements); + last = Some(SccmDeploymentPhase::Requirements); + + let Some(located) = first_fact(facts, DeploymentFactKind::ContentLocated) else { + let reason = if first_fact(facts, DeploymentFactKind::ContentRequested).is_some() { + REASON_LOCATION_RESPONSE_MISSING + } else { + match coverage_for_group(coverage, GROUP_CONTENT).map(|row| &row.state) { + Some(SccmCoverageState::Partial) => REASON_LOCATION_ROTATION, + Some(SccmCoverageState::AccessDenied) => REASON_LOCATION_ACCESS_DENIED, + _ => REASON_LOCATION_ABSENT, + } + }; + return insufficient(SccmDeploymentPhase::LocateContent, last, reason, coverage); + }; + chain.push(located); + last = Some(SccmDeploymentPhase::LocateContent); + + if let Some(failure) = unrecovered_failure( + facts, + DeploymentFactKind::TransferFailed, + DeploymentFactKind::TransferCompleted, + ) { + let started = first_fact(facts, DeploymentFactKind::TransferStarted); + let mut failed_chain = chain.clone(); + failed_chain.extend(started); + failed_chain.push(failure); + return conclude( + &failed_chain, + SccmDeploymentPhase::Transfer, + SccmDeploymentState::Failed, + SccmDeploymentClassification::ConfirmedFailure, + last, + ); + } + 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, + ); + }; + chain.push(started); + chain.push(completed); + last = Some(SccmDeploymentPhase::Transfer); + + if let Some(failure) = unrecovered_failure( + facts, + DeploymentFactKind::CacheFailed, + DeploymentFactKind::CacheCommitted, + ) { + let mut failed_chain = chain.clone(); + failed_chain.push(failure); + return conclude( + &failed_chain, + SccmDeploymentPhase::Cache, + SccmDeploymentState::Failed, + SccmDeploymentClassification::ConfirmedFailure, + last, + ); + } + let Some(cached) = first_fact(facts, DeploymentFactKind::CacheCommitted) else { + return insufficient(SccmDeploymentPhase::Cache, last, REASON_CACHE, coverage); + }; + chain.push(cached); + last = Some(SccmDeploymentPhase::Cache); + + if let Some(failure) = unrecovered_failure( + facts, + DeploymentFactKind::EnforceFailed, + DeploymentFactKind::EnforceSucceeded, + ) { + let mut failed_chain = chain.clone(); + failed_chain.push(failure); + return conclude( + &failed_chain, + SccmDeploymentPhase::Enforce, + SccmDeploymentState::Failed, + SccmDeploymentClassification::ConfirmedFailure, + last, + ); + } + let Some(enforced) = first_fact(facts, DeploymentFactKind::EnforceSucceeded) else { + return insufficient(SccmDeploymentPhase::Enforce, last, REASON_ENFORCE, coverage); + }; + chain.push(enforced); + last = Some(SccmDeploymentPhase::Enforce); + + if let Some(mismatch) = first_fact(facts, DeploymentFactKind::DetectionMismatch) { + let mut mismatch_chain = chain.clone(); + mismatch_chain.push(mismatch); + return conclude( + &mismatch_chain, + SccmDeploymentPhase::Detect, + SccmDeploymentState::DetectionMismatch, + SccmDeploymentClassification::Symptom, + last, + ); + } + let Some(detected) = first_fact(facts, DeploymentFactKind::Detected) else { + return insufficient(SccmDeploymentPhase::Detect, last, REASON_DETECT, coverage); + }; + chain.push(detected); + last = Some(SccmDeploymentPhase::Detect); + + if let Some(failure) = unrecovered_failure( + facts, + DeploymentFactKind::ReportFailed, + DeploymentFactKind::ReportSucceeded, + ) { + let mut failed_chain = chain.clone(); + failed_chain.push(failure); + return conclude( + &failed_chain, + SccmDeploymentPhase::Report, + SccmDeploymentState::Failed, + SccmDeploymentClassification::ConfirmedFailure, + last, + ); + } + let Some(reported) = first_fact(facts, DeploymentFactKind::ReportSucceeded) else { + return insufficient(SccmDeploymentPhase::Report, last, REASON_REPORT, coverage); + }; + chain.push(reported); + + conclude( + &chain, + SccmDeploymentPhase::Report, + SccmDeploymentState::Succeeded, + SccmDeploymentClassification::Success, + last, + ) +} + +/// 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. +fn conclude( + chain: &[&DeploymentFact], + phase: SccmDeploymentPhase, + state: SccmDeploymentState, + classification: SccmDeploymentClassification, + last_successful_phase: Option, +) -> Outcome { + if !chain_has_usable_order(chain) { + 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(), + }; + } + + 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(), + } +} + +fn insufficient( + phase: SccmDeploymentPhase, + last_successful_phase: Option, + reason: &str, + coverage: &[SccmDeploymentCoverage], +) -> Outcome { + let group = phase.artifact_group(); + Outcome { + phase, + state: SccmDeploymentState::InsufficientEvidence, + classification: SccmDeploymentClassification::InsufficientEvidence, + confidence: SccmDeploymentConfidence::Low, + last_successful_phase, + next_artifact: Some(SccmDeploymentArtifactRequest { + logical_artifact_id: group.to_owned(), + reason: reason.to_owned(), + }), + coverage_gap_artifact_ids: coverage_for_group(coverage, group) + .map(|row| row.artifact_ids.clone()) + .unwrap_or_default(), + } +} diff --git a/crates/cmtraceopen-parser/src/sccm/client/mod.rs b/crates/cmtraceopen-parser/src/sccm/client/mod.rs index 4ed25311c..7c00d82f8 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -4,6 +4,7 @@ //! remain outside `cmtraceopen-parser`. pub(crate) mod admission; +mod deployment; mod intake; mod inventory; mod updates; @@ -17,6 +18,7 @@ pub use admission::{ admit_client_evidence, SccmClientAdmittedEvidence, SccmClientCapturedPayload, SccmClientEvidenceAdmissionError, }; +pub use deployment::*; pub use intake::*; pub use inventory::{ analyze_client_extended, SccmClientExtendedAnalysis, SccmClientExtendedArtifactRequest, @@ -25,3 +27,15 @@ pub use inventory::{ SccmClientExtendedTransaction, SccmClientExtendedWorkflow, }; pub use updates::*; + +use super::{SccmArtifact, SccmEvidence}; + +/// Pure, normalized SCCM input shared by client workflow analyzers. +/// +/// The bundle owns no raw file handles or collection behavior. Its evidence has +/// already passed through the shared CCM logical-record scanner. +#[derive(Debug, Clone, PartialEq)] +pub struct SccmNormalizedBundle { + pub artifacts: Vec, + pub evidence: Vec, +} diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index cc7748172..96845cf03 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -2669,12 +2669,13 @@ fn unknown_role_value(role: &SccmRole) -> &str { fn coverage_state_order(coverage: &SccmCoverageState) -> u8 { match coverage { SccmCoverageState::Captured => 0, - SccmCoverageState::Absent => 1, - SccmCoverageState::AccessDenied => 2, - SccmCoverageState::Capped => 3, - SccmCoverageState::Skipped => 4, - SccmCoverageState::Unsupported => 5, - SccmCoverageState::ParseFailed => 6, + SccmCoverageState::Partial => 1, + SccmCoverageState::Absent => 2, + SccmCoverageState::AccessDenied => 3, + SccmCoverageState::Capped => 4, + SccmCoverageState::Skipped => 5, + SccmCoverageState::Unsupported => 6, + SccmCoverageState::ParseFailed => 7, } } diff --git a/crates/cmtraceopen-parser/src/sccm/models.rs b/crates/cmtraceopen-parser/src/sccm/models.rs index 47c4a5e05..eefc195a9 100644 --- a/crates/cmtraceopen-parser/src/sccm/models.rs +++ b/crates/cmtraceopen-parser/src/sccm/models.rs @@ -14,6 +14,8 @@ const INVALID_SCCM_ROLE_MESSAGE: &str = #[serde(rename_all = "camelCase")] pub enum SccmCoverageState { Captured, + /// Bytes were captured but they do not form a complete logical record. + Partial, Absent, AccessDenied, Capped, diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index d47ca5b92..2b62a75d7 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -2675,6 +2675,7 @@ fn role_sort_key(role: &SccmRole) -> &str { fn coverage_sort_key(state: &SccmCoverageState) -> &'static str { match state { SccmCoverageState::Captured => "captured", + SccmCoverageState::Partial => "partial", SccmCoverageState::Absent => "absent", SccmCoverageState::AccessDenied => "accessDenied", SccmCoverageState::Capped => "capped", diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index 8e5fbe679..cb7b2da05 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -218,7 +218,9 @@ fn declared_transaction_outcomes_are_reproduced_for_every_scenario() { ); assert_eq!( confidence_name(produced.confidence), - declared["confidence"].as_str().expect("declared confidence"), + declared["confidence"] + .as_str() + .expect("declared confidence"), "{label}: confidence" ); assert_eq!( @@ -391,7 +393,9 @@ fn counterpart_ready_facts_match_the_declared_content_request_boundary() { assert_eq!( phase_name(fact.phase), - declared_fact["phase"].as_str().expect("declared fact phase"), + declared_fact["phase"] + .as_str() + .expect("declared fact phase"), "{label}: counterpart phase" ); assert_eq!( diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index edd904b5d..4737a5e11 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -7195,6 +7195,14 @@ fn expected_catalog_tuples() -> Vec { true, true, ), + ( + "StateMessage.log", + SccmRole::Client, + "stateMessage", + SccmArtifactFamily::ClientPolicy, + true, + true, + ), ( "CAS.log", SccmRole::Client, From 37b17a514d12ffb7ce254f9ca9fa30a2aebd48ce Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:36:39 -0400 Subject: [PATCH 362/422] test(sccm): pin deployment coverage, observations, findings Refs #322 --- .../tests/sccm_client_deployment.rs | 419 +++++++++++++++++- 1 file changed, 416 insertions(+), 3 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index cb7b2da05..891feaad0 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -8,10 +8,12 @@ use std::path::{Path, PathBuf}; use cmtraceopen_parser::sccm::{ - analyze_client_deployment, normalize_ccm_artifact, SccmArtifact, SccmCoverageState, + analyze_client_deployment, declared_source_catalog, normalize_ccm_artifact, + normalize_physical_lines, SccmArtifact, SccmConfidence, SccmCoverageState, SccmDeploymentClassification, SccmDeploymentConfidence, SccmDeploymentKeyConfidence, - SccmDeploymentKeyProfileKind, SccmDeploymentPhase, SccmDeploymentState, SccmEvidence, - SccmNormalizedBundle, SccmRole, SccmRotation, SCCM_DEPLOYMENT_TEST_PROFILE_ID, + SccmDeploymentKeyProfileKind, SccmDeploymentObservationKeyConfidence, SccmDeploymentPhase, + SccmDeploymentState, SccmEvidence, SccmFindingClass, SccmNormalizedBundle, SccmRole, + SccmRotation, SCCM_DEPLOYMENT_TEST_PROFILE_ID, }; use serde_json::Value; @@ -107,7 +109,11 @@ fn load_bundle(scenario: &str) -> SccmNormalizedBundle { if let Some(relative_path) = entry["relativePath"].as_str() { let content = std::fs::read_to_string(scenario_root.join(relative_path)) .expect("declared evidence is readable UTF-8"); + // Complete logical records first, then the physical-line residue an + // intake must still surface so a fragment is visible without ever + // becoming a fact. evidence.extend(normalize_ccm_artifact(artifact.clone(), &content)); + evidence.extend(normalize_physical_lines(&artifact, &content)); } artifacts.push(artifact); } @@ -484,3 +490,410 @@ fn no_scenario_claims_a_distribution_point_or_server_cause() { ); } } + +fn coverage_state_name(state: &SccmCoverageState) -> &'static str { + match state { + SccmCoverageState::Captured => "captured", + SccmCoverageState::Partial => "partial", + SccmCoverageState::Absent => "absent", + SccmCoverageState::AccessDenied => "accessDenied", + SccmCoverageState::Capped => "capped", + SccmCoverageState::Skipped => "skipped", + SccmCoverageState::Unsupported => "unsupported", + SccmCoverageState::ParseFailed => "parseFailed", + } +} + +fn finding_class_name(class: &SccmFindingClass) -> &'static str { + match class { + SccmFindingClass::Symptom => "symptom", + SccmFindingClass::ConfirmedFailure => "confirmedFailure", + SccmFindingClass::BlockedOrDeferred => "blockedOrDeferred", + SccmFindingClass::LikelyContributor => "likelyContributor", + SccmFindingClass::InsufficientEvidence => "insufficientEvidence", + } +} + +fn shared_confidence_name(confidence: &SccmConfidence) -> &'static str { + match confidence { + SccmConfidence::None => "none", + SccmConfidence::Low => "low", + SccmConfidence::Moderate => "moderate", + SccmConfidence::High => "high", + } +} + +fn observation_key_confidence_name( + confidence: SccmDeploymentObservationKeyConfidence, +) -> &'static str { + match confidence { + SccmDeploymentObservationKeyConfidence::None => "none", + SccmDeploymentObservationKeyConfidence::Candidate => "candidate", + } +} + +fn declared_evidence_spans(value: &Value) -> Vec<(String, Option, Option)> { + value + .as_array() + .expect("declared evidence is an array") + .iter() + .map(|reference| { + ( + reference["artifactId"] + .as_str() + .expect("declared artifactId") + .to_owned(), + reference["startLine"].as_u64().map(|line| line as u32), + reference["endLine"].as_u64().map(|line| line as u32), + ) + }) + .collect() +} + +#[test] +fn declared_group_coverage_is_reproduced_for_every_scenario() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = expected["coverage"] + .as_array() + .expect("declared coverage is an array"); + + let produced = analysis + .coverage + .iter() + .map(|row| { + ( + row.logical_artifact_id.clone(), + coverage_state_name(&row.state), + ) + }) + .collect::>(); + let declared_rows = declared + .iter() + .map(|row| { + ( + row["logicalArtifactId"] + .as_str() + .expect("declared logicalArtifactId") + .to_owned(), + row["state"].as_str().expect("declared coverage state"), + ) + }) + .collect::>(); + assert_eq!(produced, declared_rows, "{scenario}: coverage rows"); + + for row in declared { + let Some(declared_ids) = row["artifactIds"].as_array() else { + continue; + }; + let logical_artifact_id = row["logicalArtifactId"] + .as_str() + .expect("declared logicalArtifactId"); + let produced_ids = analysis + .coverage + .iter() + .find(|produced| produced.logical_artifact_id == logical_artifact_id) + .map(|produced| produced.artifact_ids.clone()) + .expect("coverage row exists"); + let declared_ids = declared_ids + .iter() + .map(|id| id.as_str().expect("declared artifact id").to_owned()) + .collect::>(); + assert_eq!( + produced_ids, declared_ids, + "{scenario}/{logical_artifact_id}: partial coverage artifact ids" + ); + } + } +} + +#[test] +fn declared_next_artifacts_and_coverage_gaps_are_reproduced() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = expected["transactions"] + .as_array() + .expect("declared transactions are an array"); + + for (produced, declared) in analysis.transactions.iter().zip(declared) { + let label = format!("{scenario}/{}", produced.transaction_id); + let declared_ids = declared["coverageGapArtifactIds"] + .as_array() + .expect("declared coverage gap ids") + .iter() + .map(|id| id.as_str().expect("declared gap id").to_owned()) + .collect::>(); + assert_eq!( + produced.coverage_gap_artifact_ids, declared_ids, + "{label}: coverage gap artifact ids" + ); + + match produced.next_artifact.as_ref() { + Some(request) => { + assert_eq!( + Some(request.logical_artifact_id.as_str()), + declared["nextArtifact"]["logicalArtifactId"].as_str(), + "{label}: next artifact group" + ); + assert_eq!( + Some(request.reason.as_str()), + declared["nextArtifact"]["reason"].as_str(), + "{label}: next artifact reason" + ); + } + None => assert!( + declared["nextArtifact"].is_null(), + "{label}: unexpected next artifact" + ), + } + } + } +} + +#[test] +fn declared_source_local_observations_stay_low_and_uncorrelatable() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = expected["sourceLocalObservations"] + .as_array() + .expect("declared observations are an array"); + + assert_eq!( + analysis.source_local_observations.len(), + declared.len(), + "{scenario}: source-local observation count" + ); + + for declared in declared { + let artifact_id = declared["artifactId"] + .as_str() + .expect("declared observation artifact"); + let produced = analysis + .source_local_observations + .iter() + .find(|observation| observation.artifact_id == artifact_id) + .unwrap_or_else(|| panic!("{scenario}: no observation for {artifact_id}")); + let label = format!("{scenario}/{artifact_id}"); + + assert_eq!( + produced.complete_logical_record, + declared["completeLogicalRecord"] + .as_bool() + .expect("declared completeLogicalRecord"), + "{label}: complete logical record" + ); + assert_eq!( + observation_key_confidence_name(produced.key_confidence), + declared["keyConfidence"] + .as_str() + .expect("declared keyConfidence"), + "{label}: key confidence" + ); + assert_eq!( + confidence_name(produced.confidence_ceiling), + declared["confidenceCeiling"] + .as_str() + .expect("declared confidenceCeiling"), + "{label}: confidence ceiling" + ); + assert_eq!( + produced.correlation_eligible, + declared["correlationEligible"] + .as_bool() + .expect("declared correlationEligible"), + "{label}: correlation eligibility" + ); + assert_eq!( + ( + produced.evidence.artifact_id.clone(), + produced.evidence.line_start, + produced.evidence.line_end, + ), + ( + declared["evidence"]["artifactId"] + .as_str() + .expect("declared observation evidence artifact") + .to_owned(), + declared["evidence"]["startLine"] + .as_u64() + .map(|line| line as u32), + declared["evidence"]["endLine"] + .as_u64() + .map(|line| line as u32), + ), + "{label}: observation evidence" + ); + } + } +} + +#[test] +fn declared_findings_are_produced_and_respect_their_prohibited_claims() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + + for declared in expected["findings"] + .as_array() + .expect("declared findings are an array") + { + let finding_id = declared["findingId"].as_str().expect("declared findingId"); + let produced = analysis + .findings + .iter() + .find(|finding| finding.finding.finding_id == finding_id) + .unwrap_or_else(|| panic!("{scenario}: no finding {finding_id}")); + let label = format!("{scenario}/{finding_id}"); + + assert_eq!( + finding_class_name(&produced.finding.class), + declared["class"].as_str().expect("declared class"), + "{label}: class" + ); + assert_eq!( + phase_name(produced.deployment_phase), + declared["phase"].as_str().expect("declared phase"), + "{label}: phase" + ); + assert_eq!(produced.finding.role, SccmRole::Client, "{label}: role"); + assert_eq!( + declared["role"].as_str(), + Some("client"), + "{label}: declared role" + ); + assert_eq!( + shared_confidence_name(&produced.finding.confidence), + declared["confidence"] + .as_str() + .expect("declared confidence"), + "{label}: confidence" + ); + + let produced_spans = produced + .finding + .evidence + .iter() + .map(|reference| { + ( + reference.artifact_id.clone(), + reference.line_start, + reference.line_end, + ) + }) + .collect::>(); + assert_eq!( + produced_spans, + declared_evidence_spans(&declared["evidence"]), + "{label}: finding evidence" + ); + + let produced_gaps = produced + .finding + .coverage_gaps + .iter() + .map(|gap| gap.artifact_id.clone()) + .collect::>(); + let declared_gaps = declared["coverageGapArtifactIds"] + .as_array() + .expect("declared finding coverage gaps") + .iter() + .map(|id| id.as_str().expect("declared gap id").to_owned()) + .collect::>(); + assert_eq!( + produced_gaps, declared_gaps, + "{label}: finding coverage gaps" + ); + + let claim_text = format!( + "{} {}", + produced.finding.title.to_ascii_lowercase(), + produced.finding.summary.to_ascii_lowercase() + ); + for prohibited in declared["mustNotClaim"] + .as_array() + .expect("declared prohibited claims") + { + let prohibited = prohibited + .as_str() + .expect("declared prohibited claim") + .to_ascii_lowercase(); + assert!( + !claim_text.contains(&prohibited), + "{label}: finding claims {prohibited}" + ); + } + } + } +} + +#[test] +fn every_finding_satisfies_the_shared_finding_contract() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let catalog = declared_source_catalog(); + + for finding in &analysis.findings { + finding.finding.validate().unwrap_or_else(|error| { + panic!( + "{scenario}/{}: shared finding contract: {error:?}", + finding.finding.finding_id + ) + }); + for request in &finding.finding.next_artifacts { + assert!( + catalog + .iter() + .any(|entry| entry.logical_name == request.logical_id + && entry.role == request.role), + "{scenario}/{}: undeclared next artifact {}", + finding.finding.finding_id, + request.logical_id + ); + } + } + + for request in analysis + .findings + .iter() + .flat_map(|finding| finding.finding.next_artifacts.iter().cloned()) + { + assert!( + analysis.artifact_requests.contains(&request), + "{scenario}: aggregated artifact requests omit {}", + request.logical_id + ); + } + for gap in analysis + .findings + .iter() + .flat_map(|finding| finding.finding.coverage_gaps.iter().cloned()) + { + assert!( + analysis.coverage_gaps.contains(&gap), + "{scenario}: aggregated coverage gaps omit {}", + gap.artifact_id + ); + } + } +} + +#[test] +fn reordering_the_bundle_never_changes_the_analysis() { + for scenario in SCENARIOS { + let bundle = load_bundle(scenario); + let forward = analyze_client_deployment(&bundle); + + let reversed = SccmNormalizedBundle { + artifacts: bundle.artifacts.iter().rev().cloned().collect(), + evidence: bundle.evidence.iter().rev().cloned().collect(), + }; + assert_eq!( + analyze_client_deployment(&reversed), + forward, + "{scenario}: reordered input changed the analysis" + ); + } +} From fe551e9d2398ada0fe517ba4217d1de7d5552748 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:42:27 -0400 Subject: [PATCH 363/422] feat(sccm): emit deployment coverage, gaps, and findings Refs #322 --- .../src/sccm/client/deployment.rs | 561 +++++++++++++++++- .../cmtraceopen-parser/src/sccm/evidence.rs | 33 ++ crates/cmtraceopen-parser/src/sccm/ingest.rs | 29 + 3 files changed, 601 insertions(+), 22 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs index a87962fc8..78f070e38 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs @@ -17,9 +17,12 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::Serialize; +use crate::models::log_entry::Severity; use crate::sccm::{ - classify_artifact_name, SccmArtifact, SccmCoverageState, SccmEvidence, SccmEvidenceRef, - SccmRole, SccmTimeOrderingState, + classify_artifact_name, SccmArtifact, SccmArtifactRequest, SccmConfidence, SccmCoverageState, + SccmEvidence, SccmEvidenceRef, SccmFinding, SccmFindingBuilder, SccmFindingClass, + SccmFindingCoverageGap, SccmPhase, SccmRole, SccmRotation, SccmTerminalEvidence, + SccmTimeOrderingState, }; use super::SccmNormalizedBundle; @@ -260,6 +263,40 @@ pub struct SccmDeploymentCorrelationHandoff { 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 { @@ -268,6 +305,10 @@ pub struct SccmDeploymentAnalysis { 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, } @@ -276,7 +317,7 @@ pub fn analyze_client_deployment(bundle: &SccmNormalizedBundle) -> SccmDeploymen let coverage = coverage_rows(bundle); if bundle_identity_collides(bundle) { - return finalize(coverage, Vec::new()); + return finalize(coverage, Vec::new(), Vec::new(), Vec::new()); } // Only client-role artifacts may participate. Building this map from the @@ -310,28 +351,72 @@ pub fn analyze_client_deployment(bundle: &SccmNormalizedBundle) -> SccmDeploymen .push(fact); } - let transactions = by_assignment - .into_iter() - .filter_map(|(assignment_id, facts)| build_transaction(assignment_id, &facts, &coverage)) - .collect::>(); + let mut transactions = Vec::new(); + let mut seeds = Vec::new(); + for (assignment_id, assignment_facts) in by_assignment { + let Some((transaction, seed)) = + build_transaction(assignment_id, &assignment_facts, &coverage) + else { + continue; + }; + transactions.push(transaction); + if let Some(seed) = seed { + seeds.push(seed); + } + } + + let admitted_artifact_ids = facts + .iter() + .map(|fact| fact.reference.artifact_id.as_str()) + .collect::>(); + let observations = source_local_observations(bundle, &artifacts_by_id, &admitted_artifact_ids); + let findings = build_findings(&seeds, &artifacts_by_id); - finalize(coverage, transactions) + finalize(coverage, transactions, observations, findings) } fn finalize( coverage: Vec, transactions: Vec, + source_local_observations: Vec, + findings: 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(&coverage, &transactions), coverage, transactions, + source_local_observations, + findings, + coverage_gaps, + artifact_requests, correlation_handoff: SccmDeploymentCorrelationHandoff { issue: "#333".to_owned(), performed: false, @@ -1037,31 +1122,77 @@ struct Outcome { last_successful_phase: Option, next_artifact: Option, coverage_gap_artifact_ids: Vec, + finding_id: Option<&'static str>, + terminal_evidence: Option, +} + +/// 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: Option, + coverage_gap_artifact_ids: Vec, + coverage_gap_group: Option<&'static str>, + request_phase: Option, } fn build_transaction( assignment_id: &str, facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage], -) -> Option { +) -> 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); - - Some(SccmDeploymentTransaction { - transaction_id: format!("deployment:assignment:{assignment_id}"), - counterpart_ready_fact: counterpart_ready_fact(facts), - key, + 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, - state: outcome.state, + confidence: match outcome.confidence { + SccmDeploymentConfidence::High => SccmConfidence::High, + SccmDeploymentConfidence::Medium => SccmConfidence::Moderate, + SccmDeploymentConfidence::Low => SccmConfidence::Low, + }, 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: merged_evidence(facts), - }) + evidence: match &outcome.terminal_evidence { + Some(reference) => vec![reference.clone()], + None => evidence.clone(), + }, + terminal_evidence: outcome.terminal_evidence.clone(), + coverage_gap_artifact_ids: outcome.coverage_gap_artifact_ids.clone(), + coverage_gap_group: (outcome.classification + == SccmDeploymentClassification::InsufficientEvidence) + .then(|| outcome.phase.artifact_group()), + request_phase: outcome.next_artifact.as_ref().map(|_| outcome.phase), + }); + + Some(( + SccmDeploymentTransaction { + transaction_id: format!("deployment:assignment:{assignment_id}"), + counterpart_ready_fact: counterpart_ready_fact(facts), + key, + 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 @@ -1219,6 +1350,8 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage SccmDeploymentState::NotTargeted, SccmDeploymentClassification::NotTargeted, None, + None, + None, ); } @@ -1231,6 +1364,11 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage if let Some(failure) = first_fact(facts, DeploymentFactKind::RequirementsFailed) .or_else(|| first_fact(facts, DeploymentFactKind::DependencyFailed)) { + let finding_id = if failure.kind == DeploymentFactKind::RequirementsFailed { + FINDING_REQUIREMENTS_TERMINAL + } else { + FINDING_DEPENDENCY_TERMINAL + }; chain.push(failure); return conclude( &chain, @@ -1238,6 +1376,8 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage SccmDeploymentState::Failed, SccmDeploymentClassification::ConfirmedFailure, last, + Some(finding_id), + Some(failure), ); } let Some(requirements) = first_fact(facts, DeploymentFactKind::RequirementsSatisfied) else { @@ -1281,6 +1421,8 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage SccmDeploymentState::Failed, SccmDeploymentClassification::ConfirmedFailure, last, + Some(FINDING_TRANSFER_TERMINAL), + Some(failure), ); } let started = first_fact(facts, DeploymentFactKind::TransferStarted); @@ -1310,6 +1452,8 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage SccmDeploymentState::Failed, SccmDeploymentClassification::ConfirmedFailure, last, + Some(FINDING_CACHE_TERMINAL), + Some(failure), ); } let Some(cached) = first_fact(facts, DeploymentFactKind::CacheCommitted) else { @@ -1331,6 +1475,8 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage SccmDeploymentState::Failed, SccmDeploymentClassification::ConfirmedFailure, last, + Some(FINDING_ENFORCE_TERMINAL), + Some(failure), ); } let Some(enforced) = first_fact(facts, DeploymentFactKind::EnforceSucceeded) else { @@ -1348,6 +1494,8 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage SccmDeploymentState::DetectionMismatch, SccmDeploymentClassification::Symptom, last, + Some(FINDING_DETECTION_MISMATCH), + None, ); } let Some(detected) = first_fact(facts, DeploymentFactKind::Detected) else { @@ -1369,6 +1517,8 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage SccmDeploymentState::Failed, SccmDeploymentClassification::ConfirmedFailure, last, + Some(FINDING_REPORT_TERMINAL), + Some(failure), ); } let Some(reported) = first_fact(facts, DeploymentFactKind::ReportSucceeded) else { @@ -1382,18 +1532,23 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage SccmDeploymentState::Succeeded, SccmDeploymentClassification::Success, last, + None, + 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>, + terminal: Option<&DeploymentFact>, ) -> Outcome { if !chain_has_usable_order(chain) { return Outcome { @@ -1407,6 +1562,8 @@ fn conclude( reason: REASON_CHRONOLOGY.to_owned(), }), coverage_gap_artifact_ids: Vec::new(), + finding_id: Some(FINDING_CHRONOLOGY_UNCERTAIN), + terminal_evidence: None, }; } @@ -1426,6 +1583,8 @@ fn conclude( last_successful_phase, next_artifact: None, coverage_gap_artifact_ids: Vec::new(), + finding_id, + terminal_evidence: terminal.map(|fact| fact.reference.clone()), } } @@ -1449,5 +1608,363 @@ fn insufficient( coverage_gap_artifact_ids: coverage_for_group(coverage, group) .map(|row| row.artifact_ids.clone()) .unwrap_or_default(), + finding_id: Some(coverage_gap_finding_id(phase)), + terminal_evidence: None, + } +} + +// --------------------------------------------------------------------------- +// Source-local observations +// --------------------------------------------------------------------------- + +/// Every client artifact that carried bytes but produced no admitted fact. +/// +/// The observation exists so a reader can see the fragment; it can never be +/// promoted, ordered, or correlated. +fn source_local_observations( + bundle: &SccmNormalizedBundle, + artifacts_by_id: &BTreeMap<&str, &SccmArtifact>, + admitted_artifact_ids: &BTreeSet<&str>, +) -> Vec { + let mut evidence_by_artifact = BTreeMap::<&str, Vec<&SccmEvidence>>::new(); + for evidence in bundle + .evidence + .iter() + .filter(|evidence| evidence.role == SccmRole::Client) + { + let artifact_id = evidence.reference.artifact_id.as_str(); + if admitted_artifact_ids.contains(artifact_id) + || !artifacts_by_id.contains_key(artifact_id) + || !valid_reference(&evidence.reference) + { + continue; + } + evidence_by_artifact + .entry(artifact_id) + .or_default() + .push(evidence); + } + + evidence_by_artifact + .into_iter() + .filter_map(|(artifact_id, evidence)| { + let artifact = artifacts_by_id.get(artifact_id)?; + let complete_logical_record = artifact.coverage == SccmCoverageState::Captured; + 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!( + "{}:{artifact_id}", + if complete_logical_record { + "supplemental" + } else { + "fragment" + } + ), + artifact_id: artifact_id.to_owned(), + complete_logical_record, + key_confidence, + confidence_ceiling: SccmDeploymentConfidence::Low, + correlation_eligible: false, + reason: observation_reason(artifact).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() +} + +fn observation_reason(artifact: &SccmArtifact) -> &'static str { + match (&artifact.coverage, &artifact.rotation) { + (SccmCoverageState::Partial, SccmRotation::Current) => { + "current-file fragment cannot complete the archived physical record" + } + (SccmCoverageState::Partial, SccmRotation::LoUnderscore) => { + "archived-file fragment cannot be joined across a physical rotation boundary" + } + (SccmCoverageState::Partial, _) => { + "a physical rotation fragment cannot form a logical record" + } + (SccmCoverageState::Capped, _) => { + "capped bytes do not form a logical record and cannot attach by time" + } + _ => "unvalidated supplemental text cannot override an exact keyed client transaction", } } + +/// 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"; + +fn coverage_gap_finding_id(phase: SccmDeploymentPhase) -> &'static str { + match phase { + SccmDeploymentPhase::Intent => "deployment-intent-coverage-gap", + SccmDeploymentPhase::Requirements => "deployment-requirements-coverage-gap", + SccmDeploymentPhase::LocateContent => "deployment-location-coverage-gap", + SccmDeploymentPhase::Transfer => "deployment-transfer-coverage-gap", + SccmDeploymentPhase::Cache => "deployment-cache-coverage-gap", + SccmDeploymentPhase::Enforce => "deployment-enforce-coverage-gap", + SccmDeploymentPhase::Detect => "deployment-detect-coverage-gap", + SccmDeploymentPhase::Report => "deployment-report-coverage-gap", + } +} + +/// Titles and summaries never name a distribution point, a server, a download +/// cause, or a policy prerequisite: those claims belong to other issues. +fn finding_text(finding_id: &str) -> (&'static str, &'static str) { + match finding_id { + FINDING_REQUIREMENTS_TERMINAL => ( + "Client requirement evaluation recorded a terminal failure", + "A complete version-profiled requirement record ended this assignment before any content phase.", + ), + FINDING_DEPENDENCY_TERMINAL => ( + "Client dependency evaluation recorded a terminal failure", + "A complete version-profiled dependency record ended this assignment before any content phase.", + ), + FINDING_TRANSFER_TERMINAL => ( + "Client content transfer recorded a terminal failure", + "The same exact content key recorded a terminal transfer error after content was located.", + ), + FINDING_CACHE_TERMINAL => ( + "Client cache commit recorded a terminal failure", + "The same exact content key recorded a terminal cache error after the transfer completed.", + ), + FINDING_ENFORCE_TERMINAL => ( + "Client enforcement recorded a terminal failure", + "A complete enforcement record ended with a nonzero terminal exit code after the cache commit.", + ), + FINDING_REPORT_TERMINAL => ( + "Client deployment state report recorded a terminal failure", + "A complete state report ended this assignment with a failed deployment state.", + ), + FINDING_DETECTION_MISMATCH => ( + "Post-enforcement detection did not find the application", + "Enforcement completed with a zero exit code and detection still reported the application absent.", + ), + FINDING_CHRONOLOGY_UNCERTAIN => ( + "Deployment chronology is not usable", + "Records for this key cannot be ordered through the earlier phases, so no outcome is claimed.", + ), + "deployment-intent-coverage-gap" => ( + "Client application intent evidence is incomplete", + "No complete client intent record was available for this assignment and CI.", + ), + "deployment-requirements-coverage-gap" => ( + "Client requirement evidence is incomplete", + "No complete client requirement or dependency outcome was available for this assignment and CI.", + ), + "deployment-location-coverage-gap" => ( + "Client content-location evidence is incomplete", + "No complete client content-location record was available for this assignment and CI.", + ), + "deployment-transfer-coverage-gap" => ( + "Client content transfer evidence is incomplete", + "No complete client transfer outcome was available for this content key.", + ), + "deployment-cache-coverage-gap" => ( + "Client cache commit evidence is incomplete", + "No complete client cache commit outcome was available for this content key.", + ), + "deployment-enforce-coverage-gap" => ( + "Client enforcement evidence is incomplete", + "No complete client enforcement outcome was available for this content key.", + ), + "deployment-detect-coverage-gap" => ( + "Client detection evidence is incomplete", + "No complete client detection outcome was available for this content key.", + ), + _ => ( + "Client deployment state report evidence is incomplete", + "No complete client state report was available for this content key.", + ), + } +} + +/// The smallest catalog source that can close the gap for a phase. +fn phase_artifact_request(phase: SccmDeploymentPhase) -> SccmArtifactRequest { + let (logical_id, basename) = match phase { + SccmDeploymentPhase::Intent | SccmDeploymentPhase::Requirements => { + ("appIntentEval", "AppIntentEval.log") + } + SccmDeploymentPhase::LocateContent | SccmDeploymentPhase::Cache => ("cas", "CAS.log"), + SccmDeploymentPhase::Transfer => ("dataTransferService", "DataTransferService.log"), + SccmDeploymentPhase::Enforce => ("appEnforce", "AppEnforce.log"), + SccmDeploymentPhase::Detect => ("appDiscovery", "AppDiscovery.log"), + SccmDeploymentPhase::Report => ("stateMessage", "StateMessage.log"), + }; + SccmArtifactRequest { + logical_id: logical_id.to_owned(), + role: SccmRole::Client, + reason: format!("Collect the complete {basename} file."), + } +} + +/// Transactions blocked by the same cause share one finding: repeating an +/// identical coverage claim per assignment would overstate the evidence. +fn build_findings( + seeds: &[FindingSeed], + artifacts_by_id: &BTreeMap<&str, &SccmArtifact>, +) -> Vec { + let mut grouped = BTreeMap::<&str, Vec<&FindingSeed>>::new(); + for seed in seeds { + grouped.entry(seed.finding_id).or_default().push(seed); + } + + grouped + .into_iter() + .filter_map(|(finding_id, seeds)| { + let first = seeds.first()?; + let (title, summary) = finding_text(finding_id); + + let evidence = if first.terminal_evidence.is_some() { + 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() + .filter_map(|seed| seed.terminal_evidence.clone()) + .map(SccmTerminalEvidence::observed_failure) + .collect::>(); + terminal_evidence + .sort_by(|left, right| compare_references(&left.reference, &right.reference)); + terminal_evidence.dedup(); + + let mut coverage_gaps = seeds + .iter() + .flat_map(|seed| { + seed.coverage_gap_artifact_ids + .iter() + .filter_map(|artifact_id| { + let artifact = artifacts_by_id.get(artifact_id.as_str())?; + Some(SccmFindingCoverageGap { + artifact_id: artifact_id.clone(), + role: SccmRole::Client, + coverage: artifact.coverage.clone(), + }) + }) + .collect::>() + }) + .collect::>(); + coverage_gaps.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id)); + coverage_gaps.dedup(); + if coverage_gaps.is_empty() { + // An insufficient-evidence finding always names what is + // missing. With no incomplete artifact to blame, the gap is the + // group itself: bytes exist, the needed record does not. + if let Some(group) = first.coverage_gap_group { + coverage_gaps.push(SccmFindingCoverageGap { + artifact_id: group.to_owned(), + role: SccmRole::Client, + coverage: SccmCoverageState::Partial, + }); + } + } + + let mut builder = SccmFindingBuilder::new(finding_id) + .class(first.class.clone()) + .phase(SccmPhase::Unknown(first.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 let Some(phase) = first.request_phase { + builder = builder.next_artifact(phase_artifact_request(phase)); + } + + Some(SccmDeploymentFinding { + finding: builder + .build() + .expect("deployment finding must satisfy the shared contract"), + deployment_phase: first.phase, + last_successful_phase: first.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/evidence.rs b/crates/cmtraceopen-parser/src/sccm/evidence.rs index 1a37dac9b..54938fabe 100644 --- a/crates/cmtraceopen-parser/src/sccm/evidence.rs +++ b/crates/cmtraceopen-parser/src/sccm/evidence.rs @@ -326,6 +326,39 @@ impl SccmRawEvidenceSnapshot { } } + /// One physical line that never became a logical record. + /// + /// The snapshot carries no timestamp: a fragment has no provable instant, + /// so it can never be ordered against, or attached to, a real record. + pub(crate) fn from_physical_line( + artifact: &SccmArtifact, + line_number: u32, + text: &str, + ) -> Self { + let entry_id = format!("{}:{line_number}-{line_number}", artifact.artifact_id); + + Self { + evidence_id: entry_id.clone(), + reference: SccmEvidenceRef { + artifact_id: artifact.artifact_id.clone(), + entry_id, + line_start: Some(line_number), + line_end: Some(line_number), + }, + role: artifact.role.clone(), + component: None, + ccm_source_file: None, + message: text.to_owned(), + timestamp: SccmTimestamp { + original_display: None, + offset_minutes: None, + utc_millis: None, + ordering_state: SccmTimeOrderingState::TimestampMissing, + }, + raw_execution_context: None, + } + } + pub(crate) fn export(&self) -> SccmEvidence { SccmEvidence { evidence_id: self.evidence_id.clone(), diff --git a/crates/cmtraceopen-parser/src/sccm/ingest.rs b/crates/cmtraceopen-parser/src/sccm/ingest.rs index 9c8654793..69bc22640 100644 --- a/crates/cmtraceopen-parser/src/sccm/ingest.rs +++ b/crates/cmtraceopen-parser/src/sccm/ingest.rs @@ -9,3 +9,32 @@ pub fn normalize_ccm_artifact(artifact: SccmArtifact, content: &str) -> Vec Vec { + let covered = scan_logical_records(content, &artifact.display_name) + .into_iter() + .map(|record| (record.line_start, record.line_end)) + .collect::>(); + + content + .lines() + .enumerate() + .filter_map(|(index, line)| { + let line_number = u32::try_from(index + 1).ok()?; + let is_covered = covered + .iter() + .any(|(start, end)| (*start..=*end).contains(&line_number)); + if is_covered || line.trim().is_empty() { + return None; + } + Some(SccmRawEvidenceSnapshot::from_physical_line(artifact, line_number, line).export()) + }) + .collect() +} From bdbf0ccb7721607a71f36e3725e671c9558fa9df Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:45:06 -0400 Subject: [PATCH 364/422] test(sccm): guard deployment identity, order, and keys Each case was proven by mutating the guard it covers; all eight mutations fail the matching test and were reverted. Refs #322 --- .../tests/sccm_client_deployment.rs | 383 ++++++++++++++++++ 1 file changed, 383 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index 891feaad0..cf86b6fbf 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -897,3 +897,386 @@ fn reordering_the_bundle_never_changes_the_analysis() { ); } } + +// --------------------------------------------------------------------------- +// Synthetic adversarial contracts +// +// These cases cannot exist in the merged corpus but are exactly the ways a +// deployment reducer overstates evidence in the field. +// --------------------------------------------------------------------------- + +const ASSIGNMENT: &str = "10000000-0000-0000-0000-0000000000a1"; +const CI: &str = "20000000-0000-0000-0000-0000000000a2"; +const CONTENT: &str = "30000000-0000-0000-0000-0000000000a3"; +const REQUEST: &str = "40000000-0000-0000-0000-0000000000a4"; +const BITS_JOB: &str = "50000000-0000-0000-0000-0000000000a5"; +const PRODUCT: &str = "60000000-0000-0000-0000-0000000000a6"; +const OTHER_CI: &str = "20000000-0000-0000-0000-0000000000b2"; + +fn client_artifact(artifact_id: &str, basename: &str) -> SccmArtifact { + SccmArtifact { + artifact_id: artifact_id.to_owned(), + display_name: basename.to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.TEST.0000".to_owned()), + collected_at_utc: None, + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + } +} + +fn record(message: &str, time: &str, component: &str) -> String { + format!( + "\n" + ) +} + +fn bundle_from(sources: Vec<(SccmArtifact, String)>) -> SccmNormalizedBundle { + let mut artifacts = Vec::new(); + let mut evidence = Vec::new(); + for (artifact, content) in sources { + evidence.extend(normalize_ccm_artifact(artifact.clone(), &content)); + evidence.extend(normalize_physical_lines(&artifact, &content)); + artifacts.push(artifact); + } + SccmNormalizedBundle { + artifacts, + evidence, + } +} + +fn intent_content() -> String { + format!( + "{}{}", + record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={ASSIGNMENT} ciId={CI} state=targeted" + ), + "05:00:00.000+000", + "AppIntentEval", + ), + record( + &format!("Requirements satisfied assignmentId={ASSIGNMENT} ciId={CI}"), + "05:00:01.000+000", + "AppIntentEval", + ), + ) +} + +fn content_content() -> String { + format!( + "{}{}", + record( + &format!( + "SYNTHETIC FIXTURE deployment content located assignmentId={ASSIGNMENT} ciId={CI} packageId=LAB00021 contentId={CONTENT} contentVersion=21 distributionPointHostHandle=safe:dp:lab-dp-02 requestId={REQUEST} siteCode=LAB" + ), + "05:00:02.000+000", + "CAS", + ), + record( + &format!( + "Cache commit completed assignmentId={ASSIGNMENT} ciId={CI} contentId={CONTENT} contentVersion=21" + ), + "05:00:05.000+000", + "CAS", + ), + ) +} + +fn transfer_content(started_time: &str, completed_time: &str) -> String { + format!( + "{}{}", + record( + &format!( + "SYNTHETIC FIXTURE deployment transfer started assignmentId={ASSIGNMENT} contentId={CONTENT} contentVersion=21 requestId={REQUEST} bitsJobId={BITS_JOB}" + ), + started_time, + "DataTransferService", + ), + record( + &format!("Transfer completed assignmentId={ASSIGNMENT} contentId={CONTENT} bitsJobId={BITS_JOB}"), + completed_time, + "DataTransferService", + ), + ) +} + +fn only_transaction( + analysis: &cmtraceopen_parser::sccm::SccmDeploymentAnalysis, +) -> &cmtraceopen_parser::sccm::SccmDeploymentTransaction { + assert_eq!(analysis.transactions.len(), 1, "expected one transaction"); + &analysis.transactions[0] +} + +#[test] +fn a_duplicate_client_artifact_identity_reports_coverage_only() { + let artifact = client_artifact("synthetic-intent", "AppIntentEval.log"); + let mut bundle = bundle_from(vec![(artifact.clone(), intent_content())]); + assert_eq!( + analyze_client_deployment(&bundle).transactions.len(), + 1, + "the same bundle without a collision must produce a transaction" + ); + + bundle.artifacts.push(artifact); + let analysis = analyze_client_deployment(&bundle); + assert!( + analysis.transactions.is_empty(), + "an ambiguous artifact identity cannot elect a source" + ); + assert!(analysis.findings.is_empty()); + assert!( + !analysis.coverage.is_empty(), + "coverage still reports what was collected" + ); +} + +#[test] +fn a_duplicate_evidence_identity_reports_coverage_only() { + let artifact = client_artifact("synthetic-intent", "AppIntentEval.log"); + let mut bundle = bundle_from(vec![(artifact, intent_content())]); + bundle.evidence.extend(bundle.evidence.clone()); + + let analysis = analyze_client_deployment(&bundle); + assert!( + analysis.transactions.is_empty(), + "a duplicated logical record cannot be an authority" + ); + assert!(analysis.findings.is_empty()); +} + +#[test] +fn an_artifact_from_another_role_never_decides_a_client_source() { + let client = client_artifact("shared-identity", "AppIntentEval.log"); + let mut management_point = client.clone(); + management_point.role = SccmRole::ManagementPoint; + management_point.display_name = "mpcontrol.log".to_owned(); + + let mut forward = bundle_from(vec![(client.clone(), intent_content())]); + let mut reversed = forward.clone(); + forward.artifacts.push(management_point.clone()); + reversed.artifacts.insert(0, management_point); + + for (label, bundle) in [("client first", forward), ("client last", reversed)] { + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!( + transaction.key.assignment_id, ASSIGNMENT, + "{label}: client source was displaced by another role" + ); + assert_eq!( + phase_name(transaction.phase), + "locateContent", + "{label}: phase" + ); + } +} + +#[test] +fn an_unorderable_terminal_record_downgrades_to_a_low_confidence_symptom() { + let failing_transfer = format!( + "{}{}", + record( + &format!( + "SYNTHETIC FIXTURE deployment transfer started assignmentId={ASSIGNMENT} contentId={CONTENT} contentVersion=21 requestId={REQUEST} bitsJobId={BITS_JOB}" + ), + "05:00:03.000", + "DataTransferService", + ), + record( + &format!( + "Transfer terminal failure assignmentId={ASSIGNMENT} contentId={CONTENT} bitsJobId={BITS_JOB} errorCode=0x80070020 terminal=true" + ), + "05:00:04.000", + "DataTransferService", + ), + ); + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_content(), + ), + ( + client_artifact("synthetic-content", "CAS.log"), + content_content(), + ), + ( + client_artifact("synthetic-transfer", "DataTransferService.log"), + failing_transfer, + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!(phase_name(transaction.phase), "transfer"); + assert_eq!(state_name(transaction.state), "insufficientEvidence"); + assert_eq!(classification_name(transaction.classification), "symptom"); + assert_eq!(confidence_name(transaction.confidence), "low"); + assert!( + analysis + .findings + .iter() + .any(|finding| finding.finding.finding_id == "deployment-chronology-uncertain"), + "an unorderable chain must name the missing ordering evidence" + ); +} + +#[test] +fn a_label_embedded_in_a_longer_phrase_is_not_a_requirements_outcome() { + let embedded = format!( + "{}{}", + record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={ASSIGNMENT} ciId={CI} state=targeted" + ), + "05:00:00.000+000", + "AppIntentEval", + ), + record( + &format!("Base requirements satisfied assignmentId={ASSIGNMENT} ciId={CI}"), + "05:00:01.000+000", + "AppIntentEval", + ), + ); + let bundle = bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + embedded, + )]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!(phase_name(transaction.phase), "requirements"); + assert_eq!(state_name(transaction.state), "insufficientEvidence"); + assert_eq!( + transaction.last_successful_phase.map(phase_name), + Some("intent") + ); +} + +#[test] +fn a_duplicated_key_label_fails_closed_for_the_whole_record() { + let duplicated = record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={ASSIGNMENT} assignmentId={ASSIGNMENT} ciId={CI} state=targeted" + ), + "05:00:00.000+000", + "AppIntentEval", + ); + let bundle = bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + duplicated, + )]); + + let analysis = analyze_client_deployment(&bundle); + assert!( + analysis.transactions.is_empty(), + "a duplicated exact-token label cannot pick a value" + ); +} + +#[test] +fn two_configuration_items_under_one_assignment_never_form_a_transaction() { + let ambiguous = format!( + "{}{}", + record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={ASSIGNMENT} ciId={CI} state=targeted" + ), + "05:00:00.000+000", + "AppIntentEval", + ), + record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={ASSIGNMENT} ciId={OTHER_CI} state=targeted" + ), + "05:00:01.000+000", + "AppIntentEval", + ), + ); + let bundle = bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + ambiguous, + )]); + + let analysis = analyze_client_deployment(&bundle); + assert!( + analysis.transactions.is_empty(), + "two configuration items cannot share one exact transaction key" + ); +} + +#[test] +fn an_unprofiled_source_version_stays_a_source_local_observation() { + let mut artifact = client_artifact("synthetic-intent", "AppIntentEval.log"); + artifact.configmgr_version = Some("5.00.PROD.9128".to_owned()); + let bundle = bundle_from(vec![(artifact, intent_content())]); + + let analysis = analyze_client_deployment(&bundle); + assert!( + analysis.transactions.is_empty(), + "an unprofiled version cannot produce facts" + ); + assert_eq!(analysis.source_local_observations.len(), 1); + let observation = &analysis.source_local_observations[0]; + assert!(observation.complete_logical_record); + assert!(!observation.correlation_eligible); + assert_eq!( + confidence_name(observation.confidence_ceiling), + "low", + "an unprofiled record stays capped at low confidence" + ); +} + +#[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"); +} From 832bc03a4645db7cbb4a488bfb933740062cef53 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:45:36 -0400 Subject: [PATCH 365/422] test(sccm): pin the deployment public projection Refs #322 --- .../tests/sccm_client_deployment.rs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index cf86b6fbf..bc1e64946 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -1280,3 +1280,70 @@ fn a_nonzero_exit_code_without_a_terminal_record_is_not_a_confirmed_failure() { ); assert!(transaction.key.exit_code.is_none(), "no admitted exit code"); } + +#[test] +fn the_public_projection_is_camel_case_and_carries_no_private_material() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let json = serde_json::to_value(&analysis) + .unwrap_or_else(|error| panic!("{scenario}: analysis serializes: {error}")); + + for field in [ + "schemaVersion", + "workflow", + "extractionProfile", + "coverage", + "transactions", + "sourceLocalObservations", + "findings", + "coverageGaps", + "artifactRequests", + "correlationHandoff", + ] { + assert!( + json.get(field).is_some(), + "{scenario}: public field {field} is missing" + ); + } + + let mut keys = Vec::new(); + collect_object_keys(&json, &mut keys); + for key in keys { + assert!( + !key.contains('_') && key.chars().next().is_some_and(char::is_lowercase), + "{scenario}: public field {key} is not camelCase" + ); + } + + let text = serde_json::to_string(&analysis).expect("analysis serializes"); + for forbidden in [ + "CONTOSO", + "C:\\\\Users\\\\", + "S-1-", + "Bearer ", + "client_secret", + ] { + assert!( + !text.contains(forbidden), + "{scenario}: public projection contains {forbidden}" + ); + } + } +} + +fn collect_object_keys(value: &Value, keys: &mut Vec) { + match value { + Value::Object(object) => { + for (key, child) in object { + keys.push(key.clone()); + collect_object_keys(child, keys); + } + } + Value::Array(array) => { + for child in array { + collect_object_keys(child, keys); + } + } + _ => {} + } +} From 72ccfddc747df03a14664888934b6118e072fd26 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:47:17 -0400 Subject: [PATCH 366/422] test(sccm): pin the observed deployment extraction profile Refs #322 --- .../tests/sccm_client_deployment.rs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index bc1e64946..52145824c 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -1228,6 +1228,13 @@ fn an_unprofiled_source_version_stays_a_source_local_observation() { "low", "an unprofiled record stays capped at low confidence" ); + assert!( + analysis + .extraction_profile + .validated_artifact_families + .is_empty(), + "a captured source the profile could not read is not a validated family" + ); } #[test] @@ -1347,3 +1354,88 @@ fn collect_object_keys(value: &Value, keys: &mut Vec) { _ => {} } } + +/// Scenarios whose declared `extractionProfile` states what the bundle +/// actually produced rather than the profile's full capability list. +const OBSERVED_KEY_KIND_SCENARIOS: [&str; 8] = [ + "bits-transfer-failure", + "cache-failure", + "detection-false-negative", + "dp-content-missing", + "enforcement-exit", + "incomplete", + "rotation-boundary", + "success", +]; + +/// `rotation-boundary` additionally declares `client-content` as a validated +/// family even though none of its rotation fragments ever formed a record, so +/// its family list is not observation derived. +const OBSERVED_FAMILY_SCENARIOS: [&str; 7] = [ + "bits-transfer-failure", + "cache-failure", + "detection-false-negative", + "dp-content-missing", + "enforcement-exit", + "incomplete", + "success", +]; + +#[test] +fn the_selected_extraction_profile_reports_what_the_bundle_validated() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = &expected["extractionProfile"]; + let profile = &analysis.extraction_profile; + + assert_eq!( + Some(profile.profile_id.as_str()), + declared["profileId"].as_str(), + "{scenario}: profile id" + ); + assert_eq!( + Some(profile.source_version_prefix.as_str()), + declared["sourceVersionPrefix"].as_str(), + "{scenario}: source version prefix" + ); + assert_eq!( + Some(profile.content_version_required), + declared["contentVersionRequired"].as_bool(), + "{scenario}: content version requirement" + ); + + if OBSERVED_KEY_KIND_SCENARIOS.contains(&scenario) { + let declared_kinds = declared["keyKinds"] + .as_array() + .expect("declared key kinds") + .iter() + .map(|kind| kind.as_str().expect("declared key kind").to_owned()) + .collect::>(); + assert_eq!(profile.key_kinds, declared_kinds, "{scenario}: key kinds"); + } + + if OBSERVED_FAMILY_SCENARIOS.contains(&scenario) { + let declared_families = declared["validatedArtifactFamilies"] + .as_array() + .expect("declared validated families") + .iter() + .map(|family| family.as_str().expect("declared family").to_owned()) + .collect::>(); + assert_eq!( + profile.validated_artifact_families, declared_families, + "{scenario}: validated artifact families" + ); + } + + for family in &profile.validated_artifact_families { + assert!( + analysis + .coverage + .iter() + .any(|row| &row.logical_artifact_id == family), + "{scenario}: validated family {family} has no coverage row" + ); + } + } +} From c093e043e1043103bbc83d72566f86fea4848bc2 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 21:48:01 -0400 Subject: [PATCH 367/422] feat(sccm): validate families only where records were read Refs #322 --- .../src/sccm/client/deployment.rs | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs index 78f070e38..b0624e20a 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs @@ -317,7 +317,7 @@ pub fn analyze_client_deployment(bundle: &SccmNormalizedBundle) -> SccmDeploymen let coverage = coverage_rows(bundle); if bundle_identity_collides(bundle) { - return finalize(coverage, Vec::new(), Vec::new(), Vec::new()); + return finalize(coverage, Vec::new(), Vec::new(), Vec::new(), Vec::new()); } // Only client-role artifacts may participate. Building this map from the @@ -369,10 +369,26 @@ pub fn analyze_client_deployment(bundle: &SccmNormalizedBundle) -> SccmDeploymen .iter() .map(|fact| fact.reference.artifact_id.as_str()) .collect::>(); + // A family counts as validated only where the profile actually read a + // record. Captured bytes it could not read prove collection, not coverage + // of the workflow. + let validated_artifact_families = admitted_artifact_ids + .iter() + .filter_map(|artifact_id| artifacts_by_id.get(artifact_id)) + .map(|artifact| deployment_group_id(&artifact.display_name)) + .collect::>() + .into_iter() + .collect::>(); let observations = source_local_observations(bundle, &artifacts_by_id, &admitted_artifact_ids); let findings = build_findings(&seeds, &artifacts_by_id); - finalize(coverage, transactions, observations, findings) + finalize( + coverage, + transactions, + observations, + findings, + validated_artifact_families, + ) } fn finalize( @@ -380,6 +396,7 @@ fn finalize( transactions: Vec, source_local_observations: Vec, findings: Vec, + validated_artifact_families: Vec, ) -> SccmDeploymentAnalysis { let emitted_counterpart_ready_fact = transactions .iter() @@ -410,7 +427,7 @@ fn finalize( SccmDeploymentAnalysis { schema_version: SCCM_DEPLOYMENT_ANALYSIS_SCHEMA_VERSION, workflow: SccmDeploymentWorkflow::Deployment, - extraction_profile: extraction_profile(&coverage, &transactions), + extraction_profile: extraction_profile(validated_artifact_families, &transactions), coverage, transactions, source_local_observations, @@ -433,7 +450,7 @@ fn finalize( } fn extraction_profile( - coverage: &[SccmDeploymentCoverage], + validated_artifact_families: Vec, transactions: &[SccmDeploymentTransaction], ) -> SccmDeploymentExtractionProfile { let mut key_kinds = BTreeSet::new(); @@ -459,18 +476,6 @@ fn extraction_profile( } } - let validated_artifact_families = coverage - .iter() - .filter(|row| { - row.state == SccmCoverageState::Captured - && matches!( - row.logical_artifact_id.as_str(), - GROUP_APP_INTENT | GROUP_APP_ENFORCE | GROUP_CONTENT | GROUP_POLICY_STATE - ) - }) - .map(|row| row.logical_artifact_id.clone()) - .collect::>(); - SccmDeploymentExtractionProfile { profile_id: SCCM_DEPLOYMENT_TEST_PROFILE_ID.to_owned(), source_version_prefix: SCCM_DEPLOYMENT_TEST_VERSION_PREFIX.to_owned(), From a14ec725e4319ff51b6065e725d825a73c19fd8c Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 22:10:28 -0400 Subject: [PATCH 368/422] test(sccm): reproduce four deployment review defects Refs #322 --- .../tests/sccm_client_deployment.rs | 305 +++++++++++++++++- 1 file changed, 301 insertions(+), 4 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index 52145824c..f1b9630ed 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -1116,10 +1116,10 @@ fn an_unorderable_terminal_record_downgrades_to_a_low_confidence_symptom() { assert_eq!(classification_name(transaction.classification), "symptom"); assert_eq!(confidence_name(transaction.confidence), "low"); assert!( - analysis - .findings - .iter() - .any(|finding| finding.finding.finding_id == "deployment-chronology-uncertain"), + analysis.findings.iter().any(|finding| finding + .finding + .finding_id + .starts_with("deployment-chronology-uncertain")), "an unorderable chain must name the missing ordering evidence" ); } @@ -1439,3 +1439,300 @@ fn the_selected_extraction_profile_reports_what_the_bundle_validated() { } } } + +// --------------------------------------------------------------------------- +// Review defects: a fragment is not a record, ambiguity is not a choice, a +// boundary miss is not a discard, and a group representative may not speak for +// evidence it does not represent. +// --------------------------------------------------------------------------- + +const OTHER_ASSIGNMENT: &str = "10000000-0000-0000-0000-0000000000c1"; +const OTHER_CONTENT: &str = "30000000-0000-0000-0000-0000000000c3"; +const OTHER_REQUEST: &str = "40000000-0000-0000-0000-0000000000c4"; + +fn rotated_artifact(artifact_id: &str, basename: &str, rotation: SccmRotation) -> SccmArtifact { + let mut artifact = client_artifact(artifact_id, basename); + artifact.rotation = rotation; + artifact +} + +fn intent_record() -> String { + record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={ASSIGNMENT} ciId={CI} state=targeted" + ), + "05:00:00.000+000", + "AppIntentEval", + ) +} + +#[test] +fn a_physical_fragment_inside_a_captured_artifact_never_becomes_a_fact() { + let content = format!( + "{}Requirements satisfied assignmentId={ASSIGNMENT} ciId={CI}\n", + intent_record() + ); + let bundle = bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + content, + )]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!( + phase_name(transaction.phase), + "requirements", + "an unframed line cannot satisfy requirements" + ); + assert_eq!( + transaction.last_successful_phase.map(phase_name), + Some("intent"), + "a fragment cannot promote the last successful phase" + ); + assert!( + analysis + .source_local_observations + .iter() + .any(|observation| observation.artifact_id == "synthetic-intent" + && !observation.complete_logical_record), + "the fragment must still be visible as a source-local observation" + ); +} + +#[test] +fn a_physical_fragment_never_confirms_a_terminal_failure() { + let content = format!( + "{}Requirements terminal failure assignmentId={ASSIGNMENT} ciId={CI} requirementId=REQ-TEST-901 terminal=true\n", + intent_record() + ); + let bundle = bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + content, + )]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_ne!( + classification_name(transaction.classification), + "confirmedFailure", + "an unframed line cannot confirm a terminal failure" + ); + assert_ne!(confidence_name(transaction.confidence), "high"); +} + +#[test] +fn an_ambiguous_content_request_is_never_published_cross_side() { + let located = |content_id: &str, request_id: &str, package: &str| { + record( + &format!( + "SYNTHETIC FIXTURE deployment content located assignmentId={ASSIGNMENT} ciId={CI} packageId={package} contentId={content_id} contentVersion=21 distributionPointHostHandle=safe:dp:lab-dp-02 requestId={request_id} siteCode=LAB" + ), + "05:00:02.000+000", + "CAS", + ) + }; + + for (label, first_id, second_id) in [ + ("alphabetical", "synthetic-content-a", "synthetic-content-b"), + ("renamed", "synthetic-content-z", "synthetic-content-y"), + ] { + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_content(), + ), + ( + client_artifact(first_id, "CAS.log"), + located(CONTENT, REQUEST, "LAB00021"), + ), + ( + rotated_artifact(second_id, "CAS.log.1", SccmRotation::Numbered(1)), + located(OTHER_CONTENT, OTHER_REQUEST, "LAB00022"), + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!( + key_profile_name(transaction.key.key_profile_kind), + "assignmentCi", + "{label}: an ambiguous topology cannot key a transaction" + ); + assert!(transaction.key.content_id.is_none(), "{label}: content id"); + assert!( + transaction.counterpart_ready_fact.is_none(), + "{label}: an ambiguous content request must never be published cross-side" + ); + assert!( + !analysis.correlation_handoff.emitted_counterpart_ready_fact, + "{label}: correlation handoff flag" + ); + } +} + +#[test] +fn a_punctuation_adjacent_duplicate_label_is_ambiguity_not_a_first_win() { + let cases = [ + ( + "terminal", + format!( + "SYNTHETIC FIXTURE deployment enforcement terminal failure assignmentId={ASSIGNMENT} ciId={CI} productCode={PRODUCT} exitCode=1603 terminal=true (terminal=false)" + ), + ), + ( + "exit code", + format!( + "SYNTHETIC FIXTURE deployment enforcement terminal failure assignmentId={ASSIGNMENT} ciId={CI} productCode={PRODUCT} exitCode=1603 (exitCode=0) terminal=true" + ), + ), + ]; + + for (label, message) in cases { + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_content(), + ), + ( + client_artifact("synthetic-content", "CAS.log"), + content_content(), + ), + ( + client_artifact("synthetic-transfer", "DataTransferService.log"), + transfer_content("05:00:03.000+000", "05:00:04.000+000"), + ), + ( + client_artifact("synthetic-enforce", "AppEnforce.log"), + record(&message, "05:00:06.000+000", "AppEnforce"), + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_ne!( + classification_name(transaction.classification), + "confirmedFailure", + "{label}: a conflicting duplicate label cannot confirm a failure" + ); + assert_ne!( + confidence_name(transaction.confidence), + "high", + "{label}: confidence" + ); + } +} + +#[test] +fn a_chronology_finding_never_speaks_for_another_phase() { + let unorderable_requirements = record( + &format!( + "Requirements terminal failure assignmentId={OTHER_ASSIGNMENT} ciId={CI} requirementId=REQ-TEST-902 terminal=true" + ), + "05:00:01.000", + "AppIntentEval", + ); + let other_intent = record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={OTHER_ASSIGNMENT} ciId={CI} state=targeted" + ), + "05:00:00.000+000", + "AppIntentEval", + ); + let unorderable_enforce = record( + &format!( + "SYNTHETIC FIXTURE deployment enforcement terminal failure assignmentId={ASSIGNMENT} ciId={CI} productCode={PRODUCT} exitCode=1603 terminal=true" + ), + "05:00:06.000", + "AppEnforce", + ); + + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + format!("{}{other_intent}", intent_content()), + ), + ( + rotated_artifact( + "synthetic-intent-rotated", + "AppIntentEval.log.1", + SccmRotation::Numbered(1), + ), + unorderable_requirements, + ), + ( + client_artifact("synthetic-content", "CAS.log"), + content_content(), + ), + ( + client_artifact("synthetic-transfer", "DataTransferService.log"), + transfer_content("05:00:03.000+000", "05:00:04.000+000"), + ), + ( + client_artifact("synthetic-enforce", "AppEnforce.log"), + unorderable_enforce, + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + assert_eq!(analysis.transactions.len(), 2, "two keyed transactions"); + + let mut identifiers = analysis + .findings + .iter() + .map(|finding| finding.finding.finding_id.clone()) + .collect::>(); + let total = identifiers.len(); + identifiers.sort(); + identifiers.dedup(); + assert_eq!( + identifiers.len(), + total, + "finding identities must be unique" + ); + + let mut chronology = analysis + .findings + .iter() + .filter(|finding| { + finding + .finding + .finding_id + .starts_with("deployment-chronology-uncertain") + }) + .map(|finding| { + ( + phase_name(finding.deployment_phase), + finding + .finding + .next_artifacts + .first() + .map(|request| request.logical_id.as_str()) + .unwrap_or("none"), + ) + }) + .collect::>(); + chronology.sort(); + assert_eq!( + chronology, + vec![("enforce", "appEnforce"), ("requirements", "appIntentEval")], + "each unorderable phase must request its own artifact" + ); + + for finding in &analysis.findings { + let phase = finding.deployment_phase; + for reference in &finding.finding.evidence { + let cited_by_this_phase = analysis.transactions.iter().any(|transaction| { + transaction.phase == phase + && transaction + .evidence + .iter() + .any(|cited| cited.artifact_id == reference.artifact_id) + }); + assert!( + cited_by_this_phase, + "{}: cites {} from a transaction at another phase", + finding.finding.finding_id, reference.artifact_id + ); + } + } +} From 8af1e77a6edc5e1cea905d40e262ff2815a8119d Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 22:15:02 -0400 Subject: [PATCH 369/422] test(sccm): reject electing one of several terminal records Refs #322 --- .../src/sccm/client/deployment.rs | 182 ++++++++++++++---- .../cmtraceopen-parser/src/sccm/evidence.rs | 7 +- crates/cmtraceopen-parser/src/sccm/models.rs | 13 ++ .../tests/sccm_client_deployment.rs | 68 +++++++ .../tests/sccm_spine_contract.rs | 4 +- 5 files changed, 231 insertions(+), 43 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs index b0624e20a..40f9ab1a0 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs @@ -21,8 +21,8 @@ use crate::models::log_entry::Severity; use crate::sccm::{ classify_artifact_name, SccmArtifact, SccmArtifactRequest, SccmConfidence, SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmFinding, SccmFindingBuilder, SccmFindingClass, - SccmFindingCoverageGap, SccmPhase, SccmRole, SccmRotation, SccmTerminalEvidence, - SccmTimeOrderingState, + SccmFindingCoverageGap, SccmPhase, SccmRecordCompleteness, SccmRole, SccmRotation, + SccmTerminalEvidence, SccmTimeOrderingState, }; use super::SccmNormalizedBundle; @@ -787,10 +787,15 @@ fn parse_deployment_facts(evidence: &SccmEvidence, artifact: &SccmArtifact) -> V .collect() } -/// Version profile, role, coverage, rotation, and catalog identity must all -/// agree before a record may become a fact. +/// Record completeness, version profile, role, coverage, rotation, and catalog +/// identity must all agree before a record may become a fact. +/// +/// Completeness is read from the record, never inferred from the artifact: a +/// fully collected file can still hold a physical line that no logical record +/// covers, and that line is not evidence of anything. fn admitted_source(evidence: &SccmEvidence, artifact: &SccmArtifact) -> Option { - if artifact.role != SccmRole::Client + if evidence.completeness != SccmRecordCompleteness::LogicalRecord + || artifact.role != SccmRole::Client || evidence.role != SccmRole::Client || artifact.coverage != SccmCoverageState::Captured || !valid_reference(&evidence.reference) @@ -964,13 +969,18 @@ fn event_phrase(payload: &str) -> String { /// 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 matches = message - .match_indices(&marker) - .filter(|(index, _)| *index == 0 || message.as_bytes()[index - 1].is_ascii_whitespace()); - let (first, _) = matches.next()?; - if matches.next().is_some() { + 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; } @@ -1184,7 +1194,7 @@ fn build_transaction( Some(( SccmDeploymentTransaction { transaction_id: format!("deployment:assignment:{assignment_id}"), - counterpart_ready_fact: counterpart_ready_fact(facts), + counterpart_ready_fact: counterpart_ready_fact(facts, &key), key, phase: outcome.phase, state: outcome.state, @@ -1309,9 +1319,44 @@ fn unrecovered_failure<'a>( }) } -fn counterpart_ready_fact(facts: &[&DeploymentFact]) -> Option { - let fact = first_fact(facts, DeploymentFactKind::ContentLocated) - .or_else(|| first_fact(facts, DeploymentFactKind::ContentRequested))?; +/// 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::>(); + let [fact] = candidates.as_slice() else { + return None; + }; + + 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; @@ -1321,11 +1366,11 @@ fn counterpart_ready_fact(facts: &[&DeploymentFact]) -> Option, @@ -1638,10 +1685,7 @@ fn source_local_observations( .filter(|evidence| evidence.role == SccmRole::Client) { let artifact_id = evidence.reference.artifact_id.as_str(); - if admitted_artifact_ids.contains(artifact_id) - || !artifacts_by_id.contains_key(artifact_id) - || !valid_reference(&evidence.reference) - { + if !artifacts_by_id.contains_key(artifact_id) || !valid_reference(&evidence.reference) { continue; } evidence_by_artifact @@ -1654,20 +1698,36 @@ fn source_local_observations( .into_iter() .filter_map(|(artifact_id, evidence)| { let artifact = artifacts_by_id.get(artifact_id)?; - let complete_logical_record = artifact.coverage == SccmCoverageState::Captured; - let start = evidence + let fragments = evidence + .iter() + .copied() + .filter(|item| item.completeness == SccmRecordCompleteness::PhysicalFragment) + .collect::>(); + if fragments.is_empty() && admitted_artifact_ids.contains(artifact_id) { + return None; + } + + // Cite the fragments when there are any: they are what no fact + // represents. Otherwise the whole artifact went unrepresented. + let cited = if fragments.is_empty() { + &evidence + } else { + &fragments + }; + let start = cited .iter() .filter_map(|item| item.reference.line_start) .min()?; - let end = evidence + let end = cited .iter() .filter_map(|item| item.reference.line_end) .max()?; - let key_confidence = if evidence.iter().any(|item| has_candidate_key(&item.message)) { + let key_confidence = if cited.iter().any(|item| has_candidate_key(&item.message)) { SccmDeploymentObservationKeyConfidence::Candidate } else { SccmDeploymentObservationKeyConfidence::None }; + let complete_logical_record = observation_is_complete(artifact, &fragments); Some(SccmDeploymentObservation { observation_id: format!( @@ -1683,7 +1743,7 @@ fn source_local_observations( key_confidence, confidence_ceiling: SccmDeploymentConfidence::Low, correlation_eligible: false, - reason: observation_reason(artifact).to_owned(), + reason: observation_reason(artifact, complete_logical_record).to_owned(), evidence: SccmEvidenceRef { artifact_id: artifact_id.to_owned(), entry_id: format!("{artifact_id}:{start}-{end}"), @@ -1695,7 +1755,25 @@ fn source_local_observations( .collect() } -fn observation_reason(artifact: &SccmArtifact) -> &'static str { +/// Completeness of what the observation cites, decided by the record and by the +/// source's framing rather than by the artifact's coverage state. +/// +/// A source that frames CCM records has no complete unit smaller than a record, +/// so any fragment is incomplete. For an unframed text source the physical line +/// is itself the unit, and it is complete unless the collected bytes were cut +/// short. +fn observation_is_complete(artifact: &SccmArtifact, fragments: &[&SccmEvidence]) -> bool { + if fragments.is_empty() { + return true; + } + let catalog = classify_artifact_name(&artifact.display_name, SccmRole::Client); + !catalog.uses_ccm_records && artifact.coverage == SccmCoverageState::Captured +} + +fn observation_reason(artifact: &SccmArtifact, complete_logical_record: bool) -> &'static str { + if complete_logical_record { + return "unvalidated supplemental text cannot override an exact keyed client transaction"; + } match (&artifact.coverage, &artifact.rotation) { (SccmCoverageState::Partial, SccmRotation::Current) => { "current-file fragment cannot complete the archived physical record" @@ -1709,7 +1787,7 @@ fn observation_reason(artifact: &SccmArtifact) -> &'static str { (SccmCoverageState::Capped, _) => { "capped bytes do not form a logical record and cannot attach by time" } - _ => "unvalidated supplemental text cannot override an exact keyed client transaction", + _ => "an unframed physical line is not a logical record and cannot attach by time", } } @@ -1745,6 +1823,16 @@ const FINDING_REPORT_TERMINAL: &str = "deployment-report-terminal"; const FINDING_DETECTION_MISMATCH: &str = "deployment-detection-mismatch"; const FINDING_CHRONOLOGY_UNCERTAIN: &str = "deployment-chronology-uncertain"; +/// Most causes can only occur at one phase, so their identity is already +/// unique. An unusable chronology can occur at any of the eight, so its +/// identity carries the phase and two of them never merge. +fn emitted_finding_id(base_id: &str, phase: SccmDeploymentPhase) -> String { + if base_id == FINDING_CHRONOLOGY_UNCERTAIN { + return format!("{base_id}-{}", kebab_case(phase.as_str())); + } + base_id.to_owned() +} + fn coverage_gap_finding_id(phase: SccmDeploymentPhase) -> &'static str { match phase { SccmDeploymentPhase::Intent => "deployment-intent-coverage-gap", @@ -1854,16 +1942,28 @@ fn build_findings( seeds: &[FindingSeed], artifacts_by_id: &BTreeMap<&str, &SccmArtifact>, ) -> Vec { - let mut grouped = BTreeMap::<&str, Vec<&FindingSeed>>::new(); + let mut grouped = BTreeMap::<(&str, SccmDeploymentPhase), Vec<&FindingSeed>>::new(); for seed in seeds { - grouped.entry(seed.finding_id).or_default().push(seed); + grouped + .entry((seed.finding_id, seed.phase)) + .or_default() + .push(seed); } grouped .into_iter() - .filter_map(|(finding_id, seeds)| { + .filter_map(|((base_id, phase), seeds)| { let first = seeds.first()?; - let (title, summary) = finding_text(finding_id); + 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_some() { let mut references = seeds @@ -1919,7 +2019,7 @@ fn build_findings( let mut builder = SccmFindingBuilder::new(finding_id) .class(first.class.clone()) - .phase(SccmPhase::Unknown(first.phase.as_str().to_owned())) + .phase(SccmPhase::Unknown(phase.as_str().to_owned())) .role(SccmRole::Client) .severity(match first.class { SccmFindingClass::ConfirmedFailure => Severity::Error, @@ -1931,7 +2031,7 @@ fn build_findings( .evidence(evidence) .terminal_evidence(terminal_evidence) .coverage_gaps(coverage_gaps); - if let Some(phase) = first.request_phase { + if first.request_phase.is_some() { builder = builder.next_artifact(phase_artifact_request(phase)); } @@ -1939,8 +2039,8 @@ fn build_findings( finding: builder .build() .expect("deployment finding must satisfy the shared contract"), - deployment_phase: first.phase, - last_successful_phase: first.last_successful_phase, + deployment_phase: phase, + last_successful_phase, }) }) .collect() diff --git a/crates/cmtraceopen-parser/src/sccm/evidence.rs b/crates/cmtraceopen-parser/src/sccm/evidence.rs index 54938fabe..3c90dd052 100644 --- a/crates/cmtraceopen-parser/src/sccm/evidence.rs +++ b/crates/cmtraceopen-parser/src/sccm/evidence.rs @@ -3,7 +3,8 @@ use regex::Regex; use std::sync::OnceLock; use super::models::{ - SccmArtifact, SccmEvidence, SccmEvidenceRef, SccmRole, SccmTimeOrderingState, SccmTimestamp, + SccmArtifact, SccmEvidence, SccmEvidenceRef, SccmRecordCompleteness, SccmRole, + SccmTimeOrderingState, SccmTimestamp, }; const PUBLIC_MESSAGE_PROFILE: &str = "sccm-public-message-v1"; @@ -289,6 +290,7 @@ fn redact_email_identities(value: &str) -> String { #[derive(Debug, Clone, PartialEq)] pub(crate) struct SccmRawEvidenceSnapshot { evidence_id: String, + completeness: SccmRecordCompleteness, reference: SccmEvidenceRef, role: SccmRole, component: Option, @@ -311,6 +313,7 @@ impl SccmRawEvidenceSnapshot { Self { evidence_id: entry_id.clone(), + completeness: SccmRecordCompleteness::LogicalRecord, reference: SccmEvidenceRef { artifact_id: artifact.artifact_id.clone(), entry_id, @@ -339,6 +342,7 @@ impl SccmRawEvidenceSnapshot { Self { evidence_id: entry_id.clone(), + completeness: SccmRecordCompleteness::PhysicalFragment, reference: SccmEvidenceRef { artifact_id: artifact.artifact_id.clone(), entry_id, @@ -362,6 +366,7 @@ impl SccmRawEvidenceSnapshot { pub(crate) fn export(&self) -> SccmEvidence { SccmEvidence { evidence_id: self.evidence_id.clone(), + completeness: self.completeness, reference: self.reference.clone(), role: self.role.clone(), component: self.component.as_deref().map(project_public_text_v1), diff --git a/crates/cmtraceopen-parser/src/sccm/models.rs b/crates/cmtraceopen-parser/src/sccm/models.rs index eefc195a9..49b5434fc 100644 --- a/crates/cmtraceopen-parser/src/sccm/models.rs +++ b/crates/cmtraceopen-parser/src/sccm/models.rs @@ -168,10 +168,23 @@ pub struct SccmSensitiveHandle { pub value: String, } +/// Whether a record framed completely, measured on the record itself. +/// +/// An artifact's coverage state describes the file that was collected; it can +/// never stand in for this. A complete file may still contain a physical line +/// that no logical record covers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmRecordCompleteness { + LogicalRecord, + PhysicalFragment, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SccmEvidence { pub evidence_id: String, + pub completeness: SccmRecordCompleteness, pub reference: SccmEvidenceRef, pub role: SccmRole, pub component: Option, diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index f1b9630ed..9258d63ab 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -1736,3 +1736,71 @@ fn a_chronology_finding_never_speaks_for_another_phase() { } } } + +#[test] +fn every_equally_terminal_record_is_cited_rather_than_one_elected() { + let enforcement = |exit_code: &str, time: &str| { + record( + &format!( + "SYNTHETIC FIXTURE deployment enforcement terminal failure assignmentId={ASSIGNMENT} ciId={CI} productCode={PRODUCT} exitCode={exit_code} terminal=true" + ), + time, + "AppEnforce", + ) + }; + + for (label, first_id, second_id) in [ + ("alphabetical", "synthetic-enforce-a", "synthetic-enforce-b"), + ("renamed", "synthetic-enforce-z", "synthetic-enforce-y"), + ] { + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_content(), + ), + ( + client_artifact("synthetic-content", "CAS.log"), + content_content(), + ), + ( + client_artifact("synthetic-transfer", "DataTransferService.log"), + transfer_content("05:00:03.000+000", "05:00:04.000+000"), + ), + ( + client_artifact(first_id, "AppEnforce.log"), + enforcement("1603", "05:00:06.000+000"), + ), + ( + rotated_artifact(second_id, "AppEnforce.log.1", SccmRotation::Numbered(1)), + enforcement("1618", "05:00:07.000+000"), + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let finding = analysis + .findings + .iter() + .find(|finding| finding.finding.finding_id == "deployment-enforce-terminal") + .unwrap_or_else(|| panic!("{label}: expected an enforcement terminal finding")); + + let mut cited = finding + .finding + .terminal_evidence + .iter() + .map(|terminal| terminal.reference.artifact_id.clone()) + .collect::>(); + cited.sort(); + let mut expected = vec![first_id.to_owned(), second_id.to_owned()]; + expected.sort(); + assert_eq!( + cited, expected, + "{label}: every equally terminal record must be cited" + ); + + let transaction = only_transaction(&analysis); + assert!( + transaction.key.exit_code.is_none(), + "{label}: two conflicting exit codes cannot key the transaction" + ); + } +} diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 4737a5e11..9d9421ada 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -6,7 +6,8 @@ use cmtraceopen_parser::sccm::{ SccmConfidence, SccmCorrelationKey, SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmExtractionGapKind, SccmExtractionProfile, SccmExtractionProfileMaturity, SccmFinding, SccmFindingBuilder, SccmFindingClass, SccmFindingCoverageGap, - SccmFindingValidationError, SccmKeyConfidence, SccmKeyExtractionResult, SccmPhase, SccmRole, + SccmFindingValidationError, SccmKeyConfidence, SccmKeyExtractionResult, SccmPhase, + SccmRecordCompleteness, SccmRole, SccmRotation, SccmSignal, SccmSignalKind, SccmTerminalEvidence, SccmTerminalEvidenceKind, SccmTimeOrderingState, SccmTimestamp, SccmUnknownRotation, MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS, MAX_SCCM_NEXT_ARTIFACT_REQUESTS, @@ -31,6 +32,7 @@ fn client_policy_artifact() -> SccmArtifact { fn evidence_with_message(message: &str) -> SccmEvidence { SccmEvidence { evidence_id: "client-policy-agent:1-1".into(), + completeness: SccmRecordCompleteness::LogicalRecord, reference: SccmEvidenceRef { artifact_id: "client-policy-agent".into(), entry_id: "client-policy-agent:1-1".into(), From 2fcbe7e5b1ec9c12d13ca3b2cbf3356702a0aa87 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 22:15:44 -0400 Subject: [PATCH 370/422] fix(sccm): admit only complete records and cite every terminal Refs #322 --- .../src/sccm/client/deployment.rs | 129 +++++++++++------- .../tests/sccm_spine_contract.rs | 7 +- 2 files changed, 80 insertions(+), 56 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs index 40f9ab1a0..16718d23d 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs @@ -1138,7 +1138,7 @@ struct Outcome { next_artifact: Option, coverage_gap_artifact_ids: Vec, finding_id: Option<&'static str>, - terminal_evidence: Option, + terminal_evidence: Vec, } /// One transaction's contribution to a bundle-level finding. @@ -1149,7 +1149,7 @@ struct FindingSeed { confidence: SccmConfidence, last_successful_phase: Option, evidence: Vec, - terminal_evidence: Option, + terminal_evidence: Vec, coverage_gap_artifact_ids: Vec, coverage_gap_group: Option<&'static str>, request_phase: Option, @@ -1179,9 +1179,10 @@ fn build_transaction( SccmDeploymentConfidence::Low => SccmConfidence::Low, }, last_successful_phase: outcome.last_successful_phase, - evidence: match &outcome.terminal_evidence { - Some(reference) => vec![reference.clone()], - None => evidence.clone(), + 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(), @@ -1301,22 +1302,37 @@ fn first_fact<'a>( facts.iter().copied().find(|fact| fact.kind == kind) } -/// A failure is only unrecovered when no later success of the same phase can be -/// ordered strictly after it. -fn unrecovered_failure<'a>( +/// 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, -) -> Option<&'a DeploymentFact> { +) -> Vec<&'a DeploymentFact> { facts .iter() .copied() .filter(|fact| fact.kind == failure_kind) - .find(|failure| { + .filter(|failure| { !facts.iter().any(|candidate| { candidate.kind == success_kind && fact_is_strictly_before(failure, candidate) }) }) + .collect() +} + +fn facts_of_kind<'a>( + facts: &[&'a DeploymentFact], + kind: DeploymentFactKind, +) -> Vec<&'a DeploymentFact> { + facts + .iter() + .copied() + .filter(|fact| fact.kind == kind) + .collect() } /// The one cross-side output, and the only place a client key value leaves this @@ -1401,7 +1417,7 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage SccmDeploymentClassification::NotTargeted, None, None, - None, + &[], ); } @@ -1411,15 +1427,16 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage chain.push(intent); last = Some(SccmDeploymentPhase::Intent); - if let Some(failure) = first_fact(facts, DeploymentFactKind::RequirementsFailed) - .or_else(|| first_fact(facts, DeploymentFactKind::DependencyFailed)) - { - let finding_id = if failure.kind == DeploymentFactKind::RequirementsFailed { - FINDING_REQUIREMENTS_TERMINAL + let requirement_failures = facts_of_kind(facts, DeploymentFactKind::RequirementsFailed); + let dependency_failures = facts_of_kind(facts, DeploymentFactKind::DependencyFailed); + if !requirement_failures.is_empty() || !dependency_failures.is_empty() { + // A failed requirement gates the dependency check, so it names the + // cause when both are present; the citations stay per cause. + let (finding_id, terminals) = if requirement_failures.is_empty() { + (FINDING_DEPENDENCY_TERMINAL, dependency_failures) } else { - FINDING_DEPENDENCY_TERMINAL + (FINDING_REQUIREMENTS_TERMINAL, requirement_failures) }; - chain.push(failure); return conclude( &chain, SccmDeploymentPhase::Requirements, @@ -1427,7 +1444,7 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage SccmDeploymentClassification::ConfirmedFailure, last, Some(finding_id), - Some(failure), + &terminals, ); } let Some(requirements) = first_fact(facts, DeploymentFactKind::RequirementsSatisfied) else { @@ -1456,15 +1473,14 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage chain.push(located); last = Some(SccmDeploymentPhase::LocateContent); - if let Some(failure) = unrecovered_failure( + let transfer_failures = unrecovered_failures( facts, DeploymentFactKind::TransferFailed, DeploymentFactKind::TransferCompleted, - ) { - let started = first_fact(facts, DeploymentFactKind::TransferStarted); + ); + if !transfer_failures.is_empty() { let mut failed_chain = chain.clone(); - failed_chain.extend(started); - failed_chain.push(failure); + failed_chain.extend(first_fact(facts, DeploymentFactKind::TransferStarted)); return conclude( &failed_chain, SccmDeploymentPhase::Transfer, @@ -1472,7 +1488,7 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage SccmDeploymentClassification::ConfirmedFailure, last, Some(FINDING_TRANSFER_TERMINAL), - Some(failure), + &transfer_failures, ); } let started = first_fact(facts, DeploymentFactKind::TransferStarted); @@ -1489,21 +1505,20 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage chain.push(completed); last = Some(SccmDeploymentPhase::Transfer); - if let Some(failure) = unrecovered_failure( + let cache_failures = unrecovered_failures( facts, DeploymentFactKind::CacheFailed, DeploymentFactKind::CacheCommitted, - ) { - let mut failed_chain = chain.clone(); - failed_chain.push(failure); + ); + if !cache_failures.is_empty() { return conclude( - &failed_chain, + &chain, SccmDeploymentPhase::Cache, SccmDeploymentState::Failed, SccmDeploymentClassification::ConfirmedFailure, last, Some(FINDING_CACHE_TERMINAL), - Some(failure), + &cache_failures, ); } let Some(cached) = first_fact(facts, DeploymentFactKind::CacheCommitted) else { @@ -1512,21 +1527,20 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage chain.push(cached); last = Some(SccmDeploymentPhase::Cache); - if let Some(failure) = unrecovered_failure( + let enforce_failures = unrecovered_failures( facts, DeploymentFactKind::EnforceFailed, DeploymentFactKind::EnforceSucceeded, - ) { - let mut failed_chain = chain.clone(); - failed_chain.push(failure); + ); + if !enforce_failures.is_empty() { return conclude( - &failed_chain, + &chain, SccmDeploymentPhase::Enforce, SccmDeploymentState::Failed, SccmDeploymentClassification::ConfirmedFailure, last, Some(FINDING_ENFORCE_TERMINAL), - Some(failure), + &enforce_failures, ); } let Some(enforced) = first_fact(facts, DeploymentFactKind::EnforceSucceeded) else { @@ -1545,7 +1559,7 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage SccmDeploymentClassification::Symptom, last, Some(FINDING_DETECTION_MISMATCH), - None, + &[], ); } let Some(detected) = first_fact(facts, DeploymentFactKind::Detected) else { @@ -1554,21 +1568,20 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage chain.push(detected); last = Some(SccmDeploymentPhase::Detect); - if let Some(failure) = unrecovered_failure( + let report_failures = unrecovered_failures( facts, DeploymentFactKind::ReportFailed, DeploymentFactKind::ReportSucceeded, - ) { - let mut failed_chain = chain.clone(); - failed_chain.push(failure); + ); + if !report_failures.is_empty() { return conclude( - &failed_chain, + &chain, SccmDeploymentPhase::Report, SccmDeploymentState::Failed, SccmDeploymentClassification::ConfirmedFailure, last, Some(FINDING_REPORT_TERMINAL), - Some(failure), + &report_failures, ); } let Some(reported) = first_fact(facts, DeploymentFactKind::ReportSucceeded) else { @@ -1583,7 +1596,7 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage SccmDeploymentClassification::Success, last, None, - None, + &[], ) } @@ -1598,9 +1611,18 @@ fn conclude( classification: SccmDeploymentClassification, last_successful_phase: Option, finding_id: Option<&'static str>, - terminal: Option<&DeploymentFact>, + terminals: &[&DeploymentFact], ) -> Outcome { - if !chain_has_usable_order(chain) { + // 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, @@ -1613,7 +1635,7 @@ fn conclude( }), coverage_gap_artifact_ids: Vec::new(), finding_id: Some(FINDING_CHRONOLOGY_UNCERTAIN), - terminal_evidence: None, + terminal_evidence: Vec::new(), }; } @@ -1634,7 +1656,10 @@ fn conclude( next_artifact: None, coverage_gap_artifact_ids: Vec::new(), finding_id, - terminal_evidence: terminal.map(|fact| fact.reference.clone()), + terminal_evidence: terminals + .iter() + .map(|fact| fact.reference.clone()) + .collect(), } } @@ -1659,7 +1684,7 @@ fn insufficient( .map(|row| row.artifact_ids.clone()) .unwrap_or_default(), finding_id: Some(coverage_gap_finding_id(phase)), - terminal_evidence: None, + terminal_evidence: Vec::new(), } } @@ -1965,7 +1990,7 @@ fn build_findings( .min() .unwrap_or(first.last_successful_phase); - let evidence = if first.terminal_evidence.is_some() { + let evidence = if !first.terminal_evidence.is_empty() { let mut references = seeds .iter() .flat_map(|seed| seed.evidence.iter().cloned()) @@ -1979,7 +2004,7 @@ fn build_findings( let mut terminal_evidence = seeds .iter() - .filter_map(|seed| seed.terminal_evidence.clone()) + .flat_map(|seed| seed.terminal_evidence.iter().cloned()) .map(SccmTerminalEvidence::observed_failure) .collect::>(); terminal_evidence diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 9d9421ada..d8818fca1 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -7,10 +7,9 @@ use cmtraceopen_parser::sccm::{ SccmEvidenceRef, SccmExtractionGapKind, SccmExtractionProfile, SccmExtractionProfileMaturity, SccmFinding, SccmFindingBuilder, SccmFindingClass, SccmFindingCoverageGap, SccmFindingValidationError, SccmKeyConfidence, SccmKeyExtractionResult, SccmPhase, - SccmRecordCompleteness, SccmRole, - SccmRotation, SccmSignal, SccmSignalKind, SccmTerminalEvidence, SccmTerminalEvidenceKind, - SccmTimeOrderingState, SccmTimestamp, SccmUnknownRotation, - MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS, MAX_SCCM_NEXT_ARTIFACT_REQUESTS, + SccmRecordCompleteness, SccmRole, SccmRotation, SccmSignal, SccmSignalKind, + SccmTerminalEvidence, SccmTerminalEvidenceKind, SccmTimeOrderingState, SccmTimestamp, + SccmUnknownRotation, MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS, MAX_SCCM_NEXT_ARTIFACT_REQUESTS, SCCM_DIAGNOSTICS_SCHEMA_VERSION, }; From 92a5a8d5149b5e07a90ef8a8c6a8a61b78aa6df6 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 22:16:20 -0400 Subject: [PATCH 371/422] test(sccm): pin the declared profile selection state Refs #322 --- .../tests/sccm_client_deployment.rs | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index 9258d63ab..f2c25e23c 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -12,8 +12,8 @@ use cmtraceopen_parser::sccm::{ normalize_physical_lines, SccmArtifact, SccmConfidence, SccmCoverageState, SccmDeploymentClassification, SccmDeploymentConfidence, SccmDeploymentKeyConfidence, SccmDeploymentKeyProfileKind, SccmDeploymentObservationKeyConfidence, SccmDeploymentPhase, - SccmDeploymentState, SccmEvidence, SccmFindingClass, SccmNormalizedBundle, SccmRole, - SccmRotation, SCCM_DEPLOYMENT_TEST_PROFILE_ID, + SccmDeploymentProfileSelectionState, SccmDeploymentState, SccmEvidence, SccmFindingClass, + SccmNormalizedBundle, SccmRole, SccmRotation, SCCM_DEPLOYMENT_TEST_PROFILE_ID, }; use serde_json::Value; @@ -1804,3 +1804,34 @@ fn every_equally_terminal_record_is_cited_rather_than_one_elected() { ); } } + +fn selection_state_name(state: SccmDeploymentProfileSelectionState) -> &'static str { + match state { + SccmDeploymentProfileSelectionState::Selected => "selected", + SccmDeploymentProfileSelectionState::Unselected => "unselected", + } +} + +#[test] +fn the_extraction_profile_reports_its_selection_state() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + assert_eq!( + selection_state_name(analysis.extraction_profile.selection_state), + expected["extractionProfile"]["selectionState"] + .as_str() + .expect("declared selection state"), + "{scenario}: extraction profile selection state" + ); + } + + let mut unprofiled = client_artifact("synthetic-intent", "AppIntentEval.log"); + unprofiled.configmgr_version = Some("5.00.PROD.9128".to_owned()); + let analysis = analyze_client_deployment(&bundle_from(vec![(unprofiled, intent_content())])); + assert_eq!( + selection_state_name(analysis.extraction_profile.selection_state), + "unselected", + "no client source declares the profiled version" + ); +} From 3c0adbb1f0145fdc75c4cb27bf60b476ddbb37ab Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 22:16:34 -0400 Subject: [PATCH 372/422] feat(sccm): report the deployment profile selection state Refs #322 --- .../src/sccm/client/deployment.rs | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs index 16718d23d..e9649b0a4 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs @@ -241,9 +241,21 @@ pub struct SccmDeploymentCoverage { 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, @@ -317,7 +329,14 @@ pub fn analyze_client_deployment(bundle: &SccmNormalizedBundle) -> SccmDeploymen let coverage = coverage_rows(bundle); if bundle_identity_collides(bundle) { - return finalize(coverage, Vec::new(), Vec::new(), Vec::new(), Vec::new()); + return finalize( + selection_state(bundle), + coverage, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ); } // Only client-role artifacts may participate. Building this map from the @@ -383,6 +402,7 @@ pub fn analyze_client_deployment(bundle: &SccmNormalizedBundle) -> SccmDeploymen let findings = build_findings(&seeds, &artifacts_by_id); finalize( + selection_state(bundle), coverage, transactions, observations, @@ -391,7 +411,23 @@ pub fn analyze_client_deployment(bundle: &SccmNormalizedBundle) -> SccmDeploymen ) } +fn selection_state(bundle: &SccmNormalizedBundle) -> SccmDeploymentProfileSelectionState { + let declared = bundle.artifacts.iter().any(|artifact| { + artifact.role == SccmRole::Client + && artifact + .configmgr_version + .as_deref() + .is_some_and(|version| version.starts_with(SCCM_DEPLOYMENT_TEST_VERSION_PREFIX)) + }); + if declared { + SccmDeploymentProfileSelectionState::Selected + } else { + SccmDeploymentProfileSelectionState::Unselected + } +} + fn finalize( + selection_state: SccmDeploymentProfileSelectionState, coverage: Vec, transactions: Vec, source_local_observations: Vec, @@ -427,7 +463,11 @@ fn finalize( SccmDeploymentAnalysis { schema_version: SCCM_DEPLOYMENT_ANALYSIS_SCHEMA_VERSION, workflow: SccmDeploymentWorkflow::Deployment, - extraction_profile: extraction_profile(validated_artifact_families, &transactions), + extraction_profile: extraction_profile( + selection_state, + validated_artifact_families, + &transactions, + ), coverage, transactions, source_local_observations, @@ -450,6 +490,7 @@ fn finalize( } fn extraction_profile( + selection_state: SccmDeploymentProfileSelectionState, validated_artifact_families: Vec, transactions: &[SccmDeploymentTransaction], ) -> SccmDeploymentExtractionProfile { @@ -477,6 +518,7 @@ fn extraction_profile( } SccmDeploymentExtractionProfile { + selection_state, profile_id: SCCM_DEPLOYMENT_TEST_PROFILE_ID.to_owned(), source_version_prefix: SCCM_DEPLOYMENT_TEST_VERSION_PREFIX.to_owned(), content_version_required: true, From 8374585fdd9154e60371f2cf7f38fc95bc6d51de Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 22:18:08 -0400 Subject: [PATCH 373/422] test(sccm): pin the repeated content-request citation Refs #322 --- .../tests/sccm_client_deployment.rs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index f2c25e23c..f9caac005 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -1835,3 +1835,100 @@ fn the_extraction_profile_reports_its_selection_state() { "no client source declares the profiled version" ); } + +#[test] +fn a_repeated_identical_content_request_publishes_the_earliest_record_only() { + let located = |time: &str| { + record( + &format!( + "SYNTHETIC FIXTURE deployment content located assignmentId={ASSIGNMENT} ciId={CI} packageId=LAB00021 contentId={CONTENT} contentVersion=21 distributionPointHostHandle=safe:dp:lab-dp-02 requestId={REQUEST} siteCode=LAB" + ), + time, + "CAS", + ) + }; + + // The two records carry the same exact key, so the transaction key is not + // ambiguous. Only the citation is in question, and it must follow the + // records rather than the artifact names. + for (label, early_id, late_id) in [ + ( + "early sorts first", + "synthetic-content-a", + "synthetic-content-b", + ), + ( + "early sorts last", + "synthetic-content-z", + "synthetic-content-a", + ), + ] { + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_content(), + ), + ( + client_artifact(early_id, "CAS.log"), + located("05:00:02.000+000"), + ), + ( + rotated_artifact(late_id, "CAS.log.1", SccmRotation::Numbered(1)), + located("05:00:09.000+000"), + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + let fact = transaction + .counterpart_ready_fact + .as_ref() + .unwrap_or_else(|| panic!("{label}: an unambiguous repeated request is publishable")); + assert_eq!( + fact.evidence.artifact_id, early_id, + "{label}: the citation must follow chronology, not the artifact name" + ); + assert_eq!( + fact.timestamp_provenance.normalized_utc, "2026-07-30T05:00:02Z", + "{label}: published instant" + ); + } +} + +#[test] +fn an_unorderable_repeated_content_request_is_never_published() { + let located = |time: &str| { + record( + &format!( + "SYNTHETIC FIXTURE deployment content located assignmentId={ASSIGNMENT} ciId={CI} packageId=LAB00021 contentId={CONTENT} contentVersion=21 distributionPointHostHandle=safe:dp:lab-dp-02 requestId={REQUEST} siteCode=LAB" + ), + time, + "CAS", + ) + }; + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_content(), + ), + ( + client_artifact("synthetic-content-a", "CAS.log"), + located("05:00:02.000+000"), + ), + ( + rotated_artifact( + "synthetic-content-b", + "CAS.log.1", + SccmRotation::Numbered(1), + ), + located("05:00:09.000"), + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert!( + transaction.counterpart_ready_fact.is_none(), + "two incomparable records cannot decide which instant is published" + ); +} From 11640bdee5f503193134b79a34094337ac2c3948 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 22:18:26 -0400 Subject: [PATCH 374/422] fix(sccm): cite the earliest orderable content request Refs #322 --- .../src/sccm/client/deployment.rs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs index e9649b0a4..fd618e275 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs @@ -1396,9 +1396,10 @@ fn counterpart_ready_fact( ) }) .collect::>(); - let [fact] = candidates.as_slice() else { - return None; - }; + // 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()?; @@ -1438,6 +1439,22 @@ fn counterpart_ready_fact( }) } +/// 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 { From 51b7195cb876d5f5a3a76578d098c91d423b4b62 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 1 Aug 2026 10:06:35 -0400 Subject: [PATCH 375/422] fix(sccm): elect chain records against the chain Facts are in canonical reference order, which is artifact-major and not chronological, so first_fact could pair a start from one attempt with a completion from another. conclude then found the chain unorderable and downgraded a confirmed enforcement failure to insufficient evidence. Elect the transfer pair together and every other chain record against the current chain tail, falling back to the first of the kind so a genuinely unorderable set still fails closed. Walk the covered line spans once in normalize_physical_lines instead of rescanning every record for every line. Refs #322 --- .../src/sccm/client/deployment.rs | 119 ++++++++++++++++-- crates/cmtraceopen-parser/src/sccm/ingest.rs | 23 +++- .../tests/sccm_client_deployment.rs | 67 ++++++++++ .../tests/sccm_spine_contract.rs | 71 +++++++++-- 4 files changed, 259 insertions(+), 21 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs index fd618e275..1aa17ca85 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs @@ -1344,6 +1344,68 @@ fn first_fact<'a>( 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 @@ -1506,7 +1568,11 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage &terminals, ); } - let Some(requirements) = first_fact(facts, DeploymentFactKind::RequirementsSatisfied) else { + let Some(requirements) = next_fact_after( + facts, + DeploymentFactKind::RequirementsSatisfied, + chain.last().copied(), + ) else { return insufficient( SccmDeploymentPhase::Requirements, last, @@ -1517,7 +1583,11 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage chain.push(requirements); last = Some(SccmDeploymentPhase::Requirements); - let Some(located) = first_fact(facts, DeploymentFactKind::ContentLocated) else { + 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 { @@ -1539,7 +1609,14 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage ); if !transfer_failures.is_empty() { let mut failed_chain = chain.clone(); - failed_chain.extend(first_fact(facts, DeploymentFactKind::TransferStarted)); + failed_chain.extend( + first_fact_before( + facts, + DeploymentFactKind::TransferStarted, + &transfer_failures, + ) + .or_else(|| first_fact(facts, DeploymentFactKind::TransferStarted)), + ); return conclude( &failed_chain, SccmDeploymentPhase::Transfer, @@ -1560,6 +1637,14 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage 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); @@ -1580,7 +1665,11 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage &cache_failures, ); } - let Some(cached) = first_fact(facts, DeploymentFactKind::CacheCommitted) else { + let Some(cached) = next_fact_after( + facts, + DeploymentFactKind::CacheCommitted, + chain.last().copied(), + ) else { return insufficient(SccmDeploymentPhase::Cache, last, REASON_CACHE, coverage); }; chain.push(cached); @@ -1602,13 +1691,21 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage &enforce_failures, ); } - let Some(enforced) = first_fact(facts, DeploymentFactKind::EnforceSucceeded) else { + let Some(enforced) = next_fact_after( + facts, + DeploymentFactKind::EnforceSucceeded, + chain.last().copied(), + ) else { return insufficient(SccmDeploymentPhase::Enforce, last, REASON_ENFORCE, coverage); }; chain.push(enforced); last = Some(SccmDeploymentPhase::Enforce); - if let Some(mismatch) = first_fact(facts, DeploymentFactKind::DetectionMismatch) { + if let Some(mismatch) = next_fact_after( + facts, + DeploymentFactKind::DetectionMismatch, + chain.last().copied(), + ) { let mut mismatch_chain = chain.clone(); mismatch_chain.push(mismatch); return conclude( @@ -1621,7 +1718,9 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage &[], ); } - let Some(detected) = first_fact(facts, DeploymentFactKind::Detected) else { + let Some(detected) = + next_fact_after(facts, DeploymentFactKind::Detected, chain.last().copied()) + else { return insufficient(SccmDeploymentPhase::Detect, last, REASON_DETECT, coverage); }; chain.push(detected); @@ -1643,7 +1742,11 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage &report_failures, ); } - let Some(reported) = first_fact(facts, DeploymentFactKind::ReportSucceeded) else { + let Some(reported) = next_fact_after( + facts, + DeploymentFactKind::ReportSucceeded, + chain.last().copied(), + ) else { return insufficient(SccmDeploymentPhase::Report, last, REASON_REPORT, coverage); }; chain.push(reported); diff --git a/crates/cmtraceopen-parser/src/sccm/ingest.rs b/crates/cmtraceopen-parser/src/sccm/ingest.rs index 69bc22640..ee2b426d8 100644 --- a/crates/cmtraceopen-parser/src/sccm/ingest.rs +++ b/crates/cmtraceopen-parser/src/sccm/ingest.rs @@ -18,20 +18,33 @@ pub fn normalize_ccm_artifact(artifact: SccmArtifact, content: &str) -> Vec Vec { - let covered = scan_logical_records(content, &artifact.display_name) + let mut covered = scan_logical_records(content, &artifact.display_name) .into_iter() .map(|record| (record.line_start, record.line_end)) .collect::>(); + // Sorting the spans lets both sequences be walked once. Rescanning every + // record for every line is quadratic, and a capped or rotated source is + // exactly the large input that leaves the most uncovered lines behind. + covered.sort_unstable(); + // Records may nest or overlap, so the furthest end seen so far decides + // coverage. That is the same answer as asking whether any span contains the + // line, because every span starting at or before it has been folded in. + let mut next_span = 0usize; + let mut covered_through = 0u32; content .lines() .enumerate() .filter_map(|(index, line)| { let line_number = u32::try_from(index + 1).ok()?; - let is_covered = covered - .iter() - .any(|(start, end)| (*start..=*end).contains(&line_number)); - if is_covered || line.trim().is_empty() { + while let Some((start, end)) = covered.get(next_span).copied() { + if start > line_number { + break; + } + covered_through = covered_through.max(end); + next_span += 1; + } + if covered_through >= line_number || line.trim().is_empty() { return None; } Some(SccmRawEvidenceSnapshot::from_physical_line(artifact, line_number, line).export()) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index f9caac005..24876a8eb 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -1124,6 +1124,73 @@ fn an_unorderable_terminal_record_downgrades_to_a_low_confidence_symptom() { ); } +#[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!( diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index d8818fca1..afcc09691 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -2,14 +2,15 @@ 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, - SccmRecordCompleteness, SccmRole, SccmRotation, SccmSignal, SccmSignalKind, - SccmTerminalEvidence, SccmTerminalEvidenceKind, SccmTimeOrderingState, SccmTimestamp, - SccmUnknownRotation, MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS, MAX_SCCM_NEXT_ARTIFACT_REQUESTS, + normalize_ccm_artifact, normalize_key, normalize_physical_lines, SccmArtifact, + SccmArtifactFamily, SccmArtifactRequest, SccmConfidence, SccmCorrelationKey, + SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, SccmEvidenceRef, + SccmExtractionGapKind, SccmExtractionProfile, SccmExtractionProfileMaturity, SccmFinding, + SccmFindingBuilder, SccmFindingClass, SccmFindingCoverageGap, SccmFindingValidationError, + SccmKeyConfidence, SccmKeyExtractionResult, SccmPhase, SccmRecordCompleteness, SccmRole, + SccmRotation, SccmSignal, SccmSignalKind, SccmTerminalEvidence, SccmTerminalEvidenceKind, + SccmTimeOrderingState, SccmTimestamp, SccmUnknownRotation, + MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS, MAX_SCCM_NEXT_ARTIFACT_REQUESTS, SCCM_DIAGNOSTICS_SCHEMA_VERSION, }; @@ -6962,6 +6963,60 @@ fn catalog_rotation_grammar_preserves_unknown_suffix_and_initialism() { assert!(!class.supported_for_diagnosis); } +#[test] +fn physical_line_normalization_emits_exactly_the_lines_no_record_covers() { + // Line 1 is a fragment, lines 2-3 are one multi-line record, line 4 is a + // fragment, line 5 is blank, line 6 is a record, line 7 is a fragment. + let content = concat!( + "leading fragment\n", + "\n", + "interior fragment\n", + " \n", + "\n", + "trailing fragment\n", + ); + + let artifact = client_policy_artifact(); + let records = normalize_ccm_artifact(artifact.clone(), content); + let covered = records + .iter() + .flat_map(|record| { + let start = record.reference.line_start.expect("record line start"); + let end = record.reference.line_end.expect("record line end"); + start..=end + }) + .collect::>(); + assert!( + covered.contains(&2) && covered.contains(&3), + "the fixture must contain a multi-line record so nested spans are exercised" + ); + + let fragments = normalize_physical_lines(&artifact, content); + assert_eq!( + fragments + .iter() + .map(|fragment| fragment.reference.line_start.expect("fragment line start")) + .collect::>(), + vec![1, 4, 7], + "only uncovered, non-blank lines are fragments" + ); + for fragment in &fragments { + assert_eq!( + fragment.completeness, + SccmRecordCompleteness::PhysicalFragment + ); + assert_eq!( + fragment.reference.line_start, fragment.reference.line_end, + "a fragment spans exactly one physical line" + ); + assert!( + !covered.contains(&fragment.reference.line_start.expect("fragment line start")), + "a line a record already covers is never a fragment" + ); + } +} + #[test] fn catalog_requires_exact_producer_roles_for_server_workflow_sources() { let cases = [ From 8687417dcfd552a5a4d84a15c365b93ae62c91ce Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 03:45:10 -0400 Subject: [PATCH 376/422] feat(sccm): analyze client deployment evidence --- crates/cmtraceopen-parser/src/sccm/catalog.rs | 6 - .../src/sccm/client/deployment.rs | 275 +-- .../cmtraceopen-parser/src/sccm/client/mod.rs | 12 - .../cmtraceopen-parser/src/sccm/evidence.rs | 40 +- .../cmtraceopen-parser/src/sccm/findings.rs | 13 +- crates/cmtraceopen-parser/src/sccm/ingest.rs | 42 - crates/cmtraceopen-parser/src/sccm/models.rs | 15 - .../src/sccm/server/windows/intake.rs | 1 - .../deployment/enforcement-exit/expected.json | 4 +- .../deployment/incomplete/expected.json | 2 +- .../rotation-boundary/expected.json | 7 +- .../tests/sccm_client_deployment.rs | 765 +++++---- ...sccm_client_deployment_fixture_contract.rs | 1490 ----------------- .../tests/sccm_spine_contract.rs | 74 +- 14 files changed, 490 insertions(+), 2256 deletions(-) delete mode 100644 crates/cmtraceopen-parser/tests/sccm_client_deployment_fixture_contract.rs diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs index f28c4ffcc..21a4f990c 100644 --- a/crates/cmtraceopen-parser/src/sccm/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -400,12 +400,6 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ role: SccmRole::Client, family: SccmArtifactFamily::ClientPolicy, }, - CatalogSpec { - basename: "StateMessage", - logical_name: "stateMessage", - role: SccmRole::Client, - family: SccmArtifactFamily::ClientPolicy, - }, CatalogSpec { basename: "CAS", logical_name: "cas", diff --git a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs index 1aa17ca85..84983daee 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs @@ -1,6 +1,6 @@ //! Issue #322: application, package, and content deployment transactions. //! -//! The reducer is pure. It turns a normalized client bundle into conservative +//! 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 @@ -21,15 +21,15 @@ use crate::models::log_entry::Severity; use crate::sccm::{ classify_artifact_name, SccmArtifact, SccmArtifactRequest, SccmConfidence, SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmFinding, SccmFindingBuilder, SccmFindingClass, - SccmFindingCoverageGap, SccmPhase, SccmRecordCompleteness, SccmRole, SccmRotation, - SccmTerminalEvidence, SccmTimeOrderingState, + SccmFindingCoverageGap, SccmPhase, SccmRole, SccmTerminalEvidence, SccmTimeOrderingState, + SCCM_EXPERIMENTAL_KEY_PROFILE_ID, }; -use super::SccmNormalizedBundle; +use super::{SccmClientAdmittedEvidence, SccmClientEvidenceAdmissionError}; pub const SCCM_DEPLOYMENT_ANALYSIS_SCHEMA_VERSION: u32 = 1; -pub const SCCM_DEPLOYMENT_TEST_PROFILE_ID: &str = "deployment-client-5.00.test-v1"; -pub const SCCM_DEPLOYMENT_TEST_VERSION_PREFIX: &str = "5.00.TEST."; +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"; @@ -324,35 +324,42 @@ pub struct SccmDeploymentAnalysis { pub correlation_handoff: SccmDeploymentCorrelationHandoff, } -/// Reduce a normalized client bundle into deployment transactions. -pub fn analyze_client_deployment(bundle: &SccmNormalizedBundle) -> SccmDeploymentAnalysis { - let coverage = coverage_rows(bundle); - - if bundle_identity_collides(bundle) { - return finalize( - selection_state(bundle), - coverage, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - ); - } - - // Only client-role artifacts may participate. Building this map from the - // full artifact list would let another role's identical artifact ID decide - // which source a record came from. - let artifacts_by_id = bundle - .artifacts +/// Reduce intake-bound client evidence into deployment transactions. +pub fn analyze_client_deployment( + admitted: &SccmClientAdmittedEvidence, +) -> Result { + admitted.verify_integrity()?; + let artifacts = admitted + .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: if source.coverage == SccmCoverageState::Captured + && source.fragment_complete != Some(true) + { + SccmCoverageState::Capped + } else { + source.coverage.clone() + }, + encoding: None, + }) + .collect::>(); + let coverage = coverage_rows(&artifacts); + let artifacts_by_id = artifacts .iter() - .filter(|artifact| artifact.role == SccmRole::Client) .map(|artifact| (artifact.artifact_id.as_str(), artifact)) .collect::>(); - let mut facts = bundle - .evidence + let evidence = admitted.evidence()?; + let mut facts = evidence .iter() - .filter(|evidence| evidence.role == SccmRole::Client) .flat_map(|evidence| { artifacts_by_id .get(evidence.reference.artifact_id.as_str()) @@ -398,28 +405,22 @@ pub fn analyze_client_deployment(bundle: &SccmNormalizedBundle) -> SccmDeploymen .collect::>() .into_iter() .collect::>(); - let observations = source_local_observations(bundle, &artifacts_by_id, &admitted_artifact_ids); + let observations = + source_local_observations(evidence, &artifacts_by_id, &admitted_artifact_ids); let findings = build_findings(&seeds, &artifacts_by_id); - finalize( - selection_state(bundle), + Ok(finalize( + selection_state(evidence), coverage, transactions, observations, findings, validated_artifact_families, - ) + )) } -fn selection_state(bundle: &SccmNormalizedBundle) -> SccmDeploymentProfileSelectionState { - let declared = bundle.artifacts.iter().any(|artifact| { - artifact.role == SccmRole::Client - && artifact - .configmgr_version - .as_deref() - .is_some_and(|version| version.starts_with(SCCM_DEPLOYMENT_TEST_VERSION_PREFIX)) - }); - if declared { +fn selection_state(evidence: &[SccmEvidence]) -> SccmDeploymentProfileSelectionState { + if !evidence.is_empty() { SccmDeploymentProfileSelectionState::Selected } else { SccmDeploymentProfileSelectionState::Unselected @@ -519,66 +520,21 @@ fn extraction_profile( SccmDeploymentExtractionProfile { selection_state, - profile_id: SCCM_DEPLOYMENT_TEST_PROFILE_ID.to_owned(), - source_version_prefix: SCCM_DEPLOYMENT_TEST_VERSION_PREFIX.to_owned(), + 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, } } -// --------------------------------------------------------------------------- -// Identity guards -// --------------------------------------------------------------------------- - -/// Duplicate artifact or evidence identities make source authority ambiguous. -/// The reducer then reports coverage only: vector order must never elect one. -fn bundle_identity_collides(bundle: &SccmNormalizedBundle) -> bool { - let mut artifact_ids = BTreeSet::new(); - for artifact in bundle - .artifacts - .iter() - .filter(|artifact| artifact.role == SccmRole::Client) - { - if !artifact_ids.insert(artifact.artifact_id.as_str()) { - return true; - } - } - - let mut evidence_ids = BTreeSet::new(); - let mut references = BTreeSet::new(); - for evidence in bundle - .evidence - .iter() - .filter(|evidence| evidence.role == SccmRole::Client) - { - if !evidence_ids.insert(evidence.evidence_id.as_str()) { - return true; - } - if !references.insert(( - evidence.reference.artifact_id.as_str(), - evidence.reference.entry_id.as_str(), - evidence.reference.line_start, - evidence.reference.line_end, - )) { - return true; - } - } - - false -} - // --------------------------------------------------------------------------- // Coverage // --------------------------------------------------------------------------- -fn coverage_rows(bundle: &SccmNormalizedBundle) -> Vec { +fn coverage_rows(artifacts: &[SccmArtifact]) -> Vec { let mut grouped = BTreeMap::>::new(); - for artifact in bundle - .artifacts - .iter() - .filter(|artifact| artifact.role == SccmRole::Client) - { + for artifact in artifacts { grouped .entry(deployment_group_id(&artifact.display_name)) .or_default() @@ -613,11 +569,7 @@ fn coverage_rows(bundle: &SccmNormalizedBundle) -> Vec { /// incomplete state wins. Conflicting noncapture states stay `ParseFailed` so /// no caller can read a single cause out of a mixed group. fn combine_coverage(states: &[SccmCoverageState]) -> SccmCoverageState { - for candidate in [ - SccmCoverageState::Captured, - SccmCoverageState::Capped, - SccmCoverageState::Partial, - ] { + for candidate in [SccmCoverageState::Captured, SccmCoverageState::Capped] { if states.contains(&candidate) { return candidate; } @@ -637,13 +589,12 @@ fn combine_coverage(states: &[SccmCoverageState]) -> SccmCoverageState { fn coverage_order(coverage: &SccmCoverageState) -> u8 { match coverage { SccmCoverageState::Captured => 0, - SccmCoverageState::Partial => 1, - SccmCoverageState::Absent => 2, - SccmCoverageState::AccessDenied => 3, - SccmCoverageState::Capped => 4, - SccmCoverageState::Skipped => 5, - SccmCoverageState::Unsupported => 6, - SccmCoverageState::ParseFailed => 7, + SccmCoverageState::Absent => 1, + SccmCoverageState::AccessDenied => 2, + SccmCoverageState::Capped => 3, + SccmCoverageState::Skipped => 4, + SccmCoverageState::Unsupported => 5, + SccmCoverageState::ParseFailed => 6, } } @@ -829,28 +780,17 @@ fn parse_deployment_facts(evidence: &SccmEvidence, artifact: &SccmArtifact) -> V .collect() } -/// Record completeness, version profile, role, coverage, rotation, and catalog -/// identity must all agree before a record may become a fact. -/// -/// Completeness is read from the record, never inferred from the artifact: a -/// fully collected file can still hold a physical line that no logical record -/// covers, and that line is not evidence of anything. +/// 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 evidence.completeness != SccmRecordCompleteness::LogicalRecord - || artifact.role != SccmRole::Client + if artifact.role != SccmRole::Client || evidence.role != SccmRole::Client || artifact.coverage != SccmCoverageState::Captured || !valid_reference(&evidence.reference) { return None; } - if !artifact - .configmgr_version - .as_deref() - .is_some_and(|version| version.starts_with(SCCM_DEPLOYMENT_TEST_VERSION_PREFIX)) - { - return None; - } let catalog = classify_artifact_name(&artifact.display_name, SccmRole::Client); if !catalog.supported_for_diagnosis || artifact.rotation != catalog.rotation { @@ -1305,7 +1245,7 @@ fn build_key(assignment_id: &str, ci_id: &str, facts: &[&DeploymentFact]) -> Scc .filter_map(|fact| fact.exit_code.clone()), ), confidence: SccmDeploymentKeyConfidence::Exact, - extraction_profile_id: SCCM_DEPLOYMENT_TEST_PROFILE_ID.to_owned(), + extraction_profile_id: SCCM_DEPLOYMENT_PROFILE_ID.to_owned(), } } @@ -1486,7 +1426,7 @@ fn counterpart_ready_fact( Some(SccmDeploymentCounterpartFact { fact_kind: SccmDeploymentCounterpartFactKind::ClientContentRequest, phase: SccmDeploymentPhase::LocateContent, - extraction_profile_id: SCCM_DEPLOYMENT_TEST_PROFILE_ID.to_owned(), + extraction_profile_id: SCCM_DEPLOYMENT_PROFILE_ID.to_owned(), package_id, content_id, content_version, @@ -1592,7 +1532,7 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage REASON_LOCATION_RESPONSE_MISSING } else { match coverage_for_group(coverage, GROUP_CONTENT).map(|row| &row.state) { - Some(SccmCoverageState::Partial) => REASON_LOCATION_ROTATION, + Some(SccmCoverageState::Capped) => REASON_LOCATION_ROTATION, Some(SccmCoverageState::AccessDenied) => REASON_LOCATION_ACCESS_DENIED, _ => REASON_LOCATION_ABSENT, } @@ -1861,76 +1801,53 @@ fn insufficient( /// physical lines no record covers. The second shape is why the sweep keys on /// record completeness rather than on whether the artifact contributed facts. fn source_local_observations( - bundle: &SccmNormalizedBundle, + evidence: &[SccmEvidence], artifacts_by_id: &BTreeMap<&str, &SccmArtifact>, admitted_artifact_ids: &BTreeSet<&str>, ) -> Vec { let mut evidence_by_artifact = BTreeMap::<&str, Vec<&SccmEvidence>>::new(); - for evidence in bundle - .evidence - .iter() - .filter(|evidence| evidence.role == SccmRole::Client) - { - let artifact_id = evidence.reference.artifact_id.as_str(); - if !artifacts_by_id.contains_key(artifact_id) || !valid_reference(&evidence.reference) { + 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(evidence); + .push(item); } evidence_by_artifact .into_iter() .filter_map(|(artifact_id, evidence)| { - let artifact = artifacts_by_id.get(artifact_id)?; - let fragments = evidence - .iter() - .copied() - .filter(|item| item.completeness == SccmRecordCompleteness::PhysicalFragment) - .collect::>(); - if fragments.is_empty() && admitted_artifact_ids.contains(artifact_id) { + let _artifact = artifacts_by_id.get(artifact_id)?; + if admitted_artifact_ids.contains(artifact_id) { return None; } - - // Cite the fragments when there are any: they are what no fact - // represents. Otherwise the whole artifact went unrepresented. - let cited = if fragments.is_empty() { - &evidence - } else { - &fragments - }; - let start = cited + let start = evidence .iter() .filter_map(|item| item.reference.line_start) .min()?; - let end = cited + let end = evidence .iter() .filter_map(|item| item.reference.line_end) .max()?; - let key_confidence = if cited.iter().any(|item| has_candidate_key(&item.message)) { + let key_confidence = if evidence.iter().any(|item| has_candidate_key(&item.message)) { SccmDeploymentObservationKeyConfidence::Candidate } else { SccmDeploymentObservationKeyConfidence::None }; - let complete_logical_record = observation_is_complete(artifact, &fragments); Some(SccmDeploymentObservation { - observation_id: format!( - "{}:{artifact_id}", - if complete_logical_record { - "supplemental" - } else { - "fragment" - } - ), + observation_id: format!("supplemental:{artifact_id}"), artifact_id: artifact_id.to_owned(), - complete_logical_record, + complete_logical_record: true, key_confidence, confidence_ceiling: SccmDeploymentConfidence::Low, correlation_eligible: false, - reason: observation_reason(artifact, complete_logical_record).to_owned(), + reason: + "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}"), @@ -1942,42 +1859,6 @@ fn source_local_observations( .collect() } -/// Completeness of what the observation cites, decided by the record and by the -/// source's framing rather than by the artifact's coverage state. -/// -/// A source that frames CCM records has no complete unit smaller than a record, -/// so any fragment is incomplete. For an unframed text source the physical line -/// is itself the unit, and it is complete unless the collected bytes were cut -/// short. -fn observation_is_complete(artifact: &SccmArtifact, fragments: &[&SccmEvidence]) -> bool { - if fragments.is_empty() { - return true; - } - let catalog = classify_artifact_name(&artifact.display_name, SccmRole::Client); - !catalog.uses_ccm_records && artifact.coverage == SccmCoverageState::Captured -} - -fn observation_reason(artifact: &SccmArtifact, complete_logical_record: bool) -> &'static str { - if complete_logical_record { - return "unvalidated supplemental text cannot override an exact keyed client transaction"; - } - match (&artifact.coverage, &artifact.rotation) { - (SccmCoverageState::Partial, SccmRotation::Current) => { - "current-file fragment cannot complete the archived physical record" - } - (SccmCoverageState::Partial, SccmRotation::LoUnderscore) => { - "archived-file fragment cannot be joined across a physical rotation boundary" - } - (SccmCoverageState::Partial, _) => { - "a physical rotation fragment cannot form a logical record" - } - (SccmCoverageState::Capped, _) => { - "capped bytes do not form a logical record and cannot attach by time" - } - _ => "an unframed physical line is not a logical record and cannot attach by time", - } -} - /// A fragment may still show something that looks like a key. Saying so is not /// the same as trusting it: the observation stays capped at Low and unlinked. fn has_candidate_key(message: &str) -> bool { @@ -2199,7 +2080,7 @@ fn build_findings( coverage_gaps.push(SccmFindingCoverageGap { artifact_id: group.to_owned(), role: SccmRole::Client, - coverage: SccmCoverageState::Partial, + coverage: SccmCoverageState::Absent, }); } } diff --git a/crates/cmtraceopen-parser/src/sccm/client/mod.rs b/crates/cmtraceopen-parser/src/sccm/client/mod.rs index 7c00d82f8..1b84f430e 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -27,15 +27,3 @@ pub use inventory::{ SccmClientExtendedTransaction, SccmClientExtendedWorkflow, }; pub use updates::*; - -use super::{SccmArtifact, SccmEvidence}; - -/// Pure, normalized SCCM input shared by client workflow analyzers. -/// -/// The bundle owns no raw file handles or collection behavior. Its evidence has -/// already passed through the shared CCM logical-record scanner. -#[derive(Debug, Clone, PartialEq)] -pub struct SccmNormalizedBundle { - pub artifacts: Vec, - pub evidence: Vec, -} diff --git a/crates/cmtraceopen-parser/src/sccm/evidence.rs b/crates/cmtraceopen-parser/src/sccm/evidence.rs index 3c90dd052..1a37dac9b 100644 --- a/crates/cmtraceopen-parser/src/sccm/evidence.rs +++ b/crates/cmtraceopen-parser/src/sccm/evidence.rs @@ -3,8 +3,7 @@ use regex::Regex; use std::sync::OnceLock; use super::models::{ - SccmArtifact, SccmEvidence, SccmEvidenceRef, SccmRecordCompleteness, SccmRole, - SccmTimeOrderingState, SccmTimestamp, + SccmArtifact, SccmEvidence, SccmEvidenceRef, SccmRole, SccmTimeOrderingState, SccmTimestamp, }; const PUBLIC_MESSAGE_PROFILE: &str = "sccm-public-message-v1"; @@ -290,7 +289,6 @@ fn redact_email_identities(value: &str) -> String { #[derive(Debug, Clone, PartialEq)] pub(crate) struct SccmRawEvidenceSnapshot { evidence_id: String, - completeness: SccmRecordCompleteness, reference: SccmEvidenceRef, role: SccmRole, component: Option, @@ -313,7 +311,6 @@ impl SccmRawEvidenceSnapshot { Self { evidence_id: entry_id.clone(), - completeness: SccmRecordCompleteness::LogicalRecord, reference: SccmEvidenceRef { artifact_id: artifact.artifact_id.clone(), entry_id, @@ -329,44 +326,9 @@ impl SccmRawEvidenceSnapshot { } } - /// One physical line that never became a logical record. - /// - /// The snapshot carries no timestamp: a fragment has no provable instant, - /// so it can never be ordered against, or attached to, a real record. - pub(crate) fn from_physical_line( - artifact: &SccmArtifact, - line_number: u32, - text: &str, - ) -> Self { - let entry_id = format!("{}:{line_number}-{line_number}", artifact.artifact_id); - - Self { - evidence_id: entry_id.clone(), - completeness: SccmRecordCompleteness::PhysicalFragment, - reference: SccmEvidenceRef { - artifact_id: artifact.artifact_id.clone(), - entry_id, - line_start: Some(line_number), - line_end: Some(line_number), - }, - role: artifact.role.clone(), - component: None, - ccm_source_file: None, - message: text.to_owned(), - timestamp: SccmTimestamp { - original_display: None, - offset_minutes: None, - utc_millis: None, - ordering_state: SccmTimeOrderingState::TimestampMissing, - }, - raw_execution_context: None, - } - } - pub(crate) fn export(&self) -> SccmEvidence { SccmEvidence { evidence_id: self.evidence_id.clone(), - completeness: self.completeness, reference: self.reference.clone(), role: self.role.clone(), component: self.component.as_deref().map(project_public_text_v1), diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 96845cf03..cc7748172 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -2669,13 +2669,12 @@ fn unknown_role_value(role: &SccmRole) -> &str { fn coverage_state_order(coverage: &SccmCoverageState) -> u8 { match coverage { SccmCoverageState::Captured => 0, - SccmCoverageState::Partial => 1, - SccmCoverageState::Absent => 2, - SccmCoverageState::AccessDenied => 3, - SccmCoverageState::Capped => 4, - SccmCoverageState::Skipped => 5, - SccmCoverageState::Unsupported => 6, - SccmCoverageState::ParseFailed => 7, + 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/ingest.rs b/crates/cmtraceopen-parser/src/sccm/ingest.rs index ee2b426d8..9c8654793 100644 --- a/crates/cmtraceopen-parser/src/sccm/ingest.rs +++ b/crates/cmtraceopen-parser/src/sccm/ingest.rs @@ -9,45 +9,3 @@ pub fn normalize_ccm_artifact(artifact: SccmArtifact, content: &str) -> Vec Vec { - let mut covered = scan_logical_records(content, &artifact.display_name) - .into_iter() - .map(|record| (record.line_start, record.line_end)) - .collect::>(); - // Sorting the spans lets both sequences be walked once. Rescanning every - // record for every line is quadratic, and a capped or rotated source is - // exactly the large input that leaves the most uncovered lines behind. - covered.sort_unstable(); - - // Records may nest or overlap, so the furthest end seen so far decides - // coverage. That is the same answer as asking whether any span contains the - // line, because every span starting at or before it has been folded in. - let mut next_span = 0usize; - let mut covered_through = 0u32; - content - .lines() - .enumerate() - .filter_map(|(index, line)| { - let line_number = u32::try_from(index + 1).ok()?; - while let Some((start, end)) = covered.get(next_span).copied() { - if start > line_number { - break; - } - covered_through = covered_through.max(end); - next_span += 1; - } - if covered_through >= line_number || line.trim().is_empty() { - return None; - } - Some(SccmRawEvidenceSnapshot::from_physical_line(artifact, line_number, line).export()) - }) - .collect() -} diff --git a/crates/cmtraceopen-parser/src/sccm/models.rs b/crates/cmtraceopen-parser/src/sccm/models.rs index 49b5434fc..47c4a5e05 100644 --- a/crates/cmtraceopen-parser/src/sccm/models.rs +++ b/crates/cmtraceopen-parser/src/sccm/models.rs @@ -14,8 +14,6 @@ const INVALID_SCCM_ROLE_MESSAGE: &str = #[serde(rename_all = "camelCase")] pub enum SccmCoverageState { Captured, - /// Bytes were captured but they do not form a complete logical record. - Partial, Absent, AccessDenied, Capped, @@ -168,23 +166,10 @@ pub struct SccmSensitiveHandle { pub value: String, } -/// Whether a record framed completely, measured on the record itself. -/// -/// An artifact's coverage state describes the file that was collected; it can -/// never stand in for this. A complete file may still contain a physical line -/// that no logical record covers. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum SccmRecordCompleteness { - LogicalRecord, - PhysicalFragment, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SccmEvidence { pub evidence_id: String, - pub completeness: SccmRecordCompleteness, pub reference: SccmEvidenceRef, pub role: SccmRole, pub component: Option, diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 2b62a75d7..d47ca5b92 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -2675,7 +2675,6 @@ fn role_sort_key(role: &SccmRole) -> &str { fn coverage_sort_key(state: &SccmCoverageState) -> &'static str { match state { SccmCoverageState::Captured => "captured", - SccmCoverageState::Partial => "partial", SccmCoverageState::Absent => "absent", SccmCoverageState::AccessDenied => "accessDenied", SccmCoverageState::Capped => "capped", diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/enforcement-exit/expected.json index e2afe8804..b9b666b8c 100644 --- 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 @@ -6,7 +6,7 @@ "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"},{"logicalArtifactId":"client-installer-supplemental","state":"captured"}], + "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}, @@ -21,7 +21,7 @@ "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":[{"observationId":"supplemental:installer:enforcement-exit","artifactId":"deployment-enforcement-exit-supplemental-current","completeLogicalRecord":true,"keyConfidence":"none","confidenceCeiling":"low","correlationEligible":false,"reason":"unvalidated supplemental text at a similar time cannot override the exact AppEnforce transaction","evidence":{"artifactId":"deployment-enforcement-exit-supplemental-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":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/incomplete/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/expected.json index 129f26f39..17edd8e9b 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete/expected.json @@ -15,7 +15,7 @@ {"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":[{"observationId":"fragment:deployment-incomplete-enforce-capped","artifactId":"deployment-incomplete-enforce-capped","completeLogicalRecord":false,"keyConfidence":"none","confidenceCeiling":"low","correlationEligible":false,"reason":"capped enforcement bytes do not form a logical record and cannot attach by time","evidence":{"artifactId":"deployment-incomplete-enforce-capped","startLine":1,"endLine":1}}], + "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}, diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/expected.json index dae04c8c6..674b263a1 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/expected.json @@ -6,7 +6,7 @@ "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","client-content"]}, "reorderedInputDeterministic":true, - "coverage":[{"logicalArtifactId":"client-app-intent","state":"captured"},{"logicalArtifactId":"client-content","state":"partial","artifactIds":["deployment-rotation-boundary-content-current","deployment-rotation-boundary-content-lo"]}], + "coverage":[{"logicalArtifactId":"client-app-intent","state":"captured"},{"logicalArtifactId":"client-content","state":"capped","artifactIds":["deployment-rotation-boundary-content-current","deployment-rotation-boundary-content-lo"]}], "artifactProvenance":[ {"artifactId":"deployment-rotation-boundary-content-current","bytesCopied":207,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, {"artifactId":"deployment-rotation-boundary-content-lo","bytesCopied":147,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, @@ -19,10 +19,7 @@ "phase":"locateContent","state":"insufficientEvidence","lastSuccessfulPhase":"requirements","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low","coverageGapArtifactIds":["deployment-rotation-boundary-content-current","deployment-rotation-boundary-content-lo"],"nextArtifact":{"logicalArtifactId":"client-content","reason":"capture a complete logical CCM content record without joining physical rotation fragments"}, "evidence":[{"artifactId":"deployment-rotation-boundary-intent-current","startLine":1,"endLine":2}] }], - "sourceLocalObservations":[ - {"observationId":"fragment:deployment-rotation-boundary-content-current","artifactId":"deployment-rotation-boundary-content-current","completeLogicalRecord":false,"keyConfidence":"candidate","confidenceCeiling":"low","correlationEligible":false,"reason":"current-file fragment cannot complete the archived physical record","evidence":{"artifactId":"deployment-rotation-boundary-content-current","startLine":1,"endLine":1}}, - {"observationId":"fragment:deployment-rotation-boundary-content-lo","artifactId":"deployment-rotation-boundary-content-lo","completeLogicalRecord":false,"keyConfidence":"candidate","confidenceCeiling":"low","correlationEligible":false,"reason":"archived-file fragment cannot be joined across a physical rotation boundary","evidence":{"artifactId":"deployment-rotation-boundary-content-lo","startLine":1,"endLine":1}} - ], + "sourceLocalObservations":[], "findings":[], "adversarialControls":{"crossPhysicalFileRecordJoinForbidden":true,"canonicalLoSuffix":".lo_","sameTimestampDoesNotRepairFragment":true}, "correlationHandoff":{"issue":"#333","performed":false,"timeOnlyEligible":false,"topologyCompatibilityEvaluated":false,"serverCauseClaimed":false,"counterpartReadyKeyKinds":["contentId","contentVersion","distributionPointHostHandle","packageId","requestId"],"emittedCounterpartReadyFact":false}, diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index 24876a8eb..41bca4f73 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -2,20 +2,26 @@ //! //! Every expectation is read from the merged issue #322 fixture corpus under //! `tests/fixtures/sccm/client/deployment`. The corpus is the specification: -//! this file only translates its declared manifests into a normalized bundle +//! this file only translates its declared manifests through canonical intake //! and compares the reducer output against the declared expectations. -use std::path::{Path, PathBuf}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; use cmtraceopen_parser::sccm::{ - analyze_client_deployment, declared_source_catalog, normalize_ccm_artifact, - normalize_physical_lines, SccmArtifact, SccmConfidence, SccmCoverageState, - SccmDeploymentClassification, SccmDeploymentConfidence, SccmDeploymentKeyConfidence, - SccmDeploymentKeyProfileKind, SccmDeploymentObservationKeyConfidence, SccmDeploymentPhase, - SccmDeploymentProfileSelectionState, SccmDeploymentState, SccmEvidence, SccmFindingClass, - SccmNormalizedBundle, SccmRole, SccmRotation, SCCM_DEPLOYMENT_TEST_PROFILE_ID, + 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", @@ -32,6 +38,57 @@ const SCENARIOS: [&str; 12] = [ "success", ]; +const FULL_OUTPUT_SHA256: [(&str, &str); 12] = [ + ( + "bits-transfer-failure", + "e86ee7550f6a229086ef08f2aaa8c7c291cfa69872abc946d3e47851ed630ff4", + ), + ( + "cache-failure", + "7c22725d771fcdd61104093a46aedd8c570a59ee016d9d982165b9a929decc96", + ), + ( + "dependency-failure", + "411da99d3414192f101873528af2c6fc1173fd0e5b9f2e923b1bc0c3f5121c97", + ), + ( + "detection-false-negative", + "ef4b920914e7ea6735995c5ea63b8ff87b02903dc9858d74545c8b0a31edf778", + ), + ( + "dp-content-missing", + "8869c0fe634ef1cbebd0581d4ee40453459ea464a25a7ea152c37d00f35dc2af", + ), + ( + "enforcement-exit", + "3f7c9ac906522dc9d681d28302135d080e8657024cd8eae7801174583cb77e3c", + ), + ( + "incomplete", + "284852fe6062bfdff091d09156771a91d96dc32c67355d912fafc28cf16ec7f4", + ), + ( + "location-missing", + "b3bd1cbeae6f9b0e66912f59e27e07204a4905a61e47c7fc0c06abf5ed660c81", + ), + ( + "not-targeted", + "a859754fc0938b5b5f1a3bc1f24570c6ce373fb5ceeed631506efc9c58e871ab", + ), + ( + "requirements-failure", + "03919a838a19f744b3f0dea45d44bb9bd00b2acda9a5a8d83542515a41179577", + ), + ( + "rotation-boundary", + "4f9dab01e081ffd7c73704948e0c2961c75035997ad1e0ac05202136d841b987", + ), + ( + "success", + "71bf6d496fde8b7576d38718f33809a146e7ad0283562f729228f28fd3598c6f", + ), +]; + fn deployment_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/client/deployment") } @@ -44,84 +101,169 @@ fn load_json(path: &Path) -> Value { } fn expected(scenario: &str) -> Value { - load_json(&deployment_root().join(scenario).join("expected.json")) + 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 } -/// Translate one declared manifest artifact into the shared spine artifact. -/// -/// `captureState` plus `rotation.fragmentComplete` collapse into a single -/// coverage state: captured bytes that do not form a complete logical record -/// are `Partial`, never `Captured`. -fn artifact_from_manifest(entry: &Value) -> SccmArtifact { - let capture_state = entry["captureState"] - .as_str() - .expect("captureState is a string"); - let fragment_complete = entry["rotation"]["fragmentComplete"] - .as_bool() - .expect("fragmentComplete is a bool"); - let coverage = match capture_state { - "captured" if fragment_complete => SccmCoverageState::Captured, - "captured" => SccmCoverageState::Partial, - "capped" => SccmCoverageState::Capped, - "absent" => SccmCoverageState::Absent, - "accessDenied" => SccmCoverageState::AccessDenied, - "skipped" => SccmCoverageState::Skipped, - "unsupported" => SccmCoverageState::Unsupported, - other => panic!("unsupported captureState {other}"), - }; - let rotation = match entry["rotation"]["kind"].as_str() { - Some("current") => SccmRotation::Current, - Some("lo") => SccmRotation::LoUnderscore, - other => panic!("unsupported rotation kind {other:?}"), - }; - - SccmArtifact { - artifact_id: entry["artifactId"] - .as_str() - .expect("artifactId is a string") - .to_owned(), - display_name: entry["originalBasename"] - .as_str() - .expect("originalBasename is a string") - .to_owned(), - original_path: None, - host: None, - role: SccmRole::Client, - configmgr_version: entry["sourceVersion"].as_str().map(str::to_owned), - collected_at_utc: entry["capturedUtc"].as_str().map(str::to_owned), - rotation, - coverage, - encoding: entry["encoding"].as_str().map(str::to_owned), +fn 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 load_bundle(scenario: &str) -> SccmNormalizedBundle { +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 evidence: Vec = Vec::new(); + let mut payloads = Vec::new(); - for entry in manifest["artifacts"] + for (index, entry) in manifest["artifacts"] .as_array() .expect("manifest artifacts are an array") + .iter() + .enumerate() { - let artifact = artifact_from_manifest(entry); - if let Some(relative_path) = entry["relativePath"].as_str() { - let content = std::fs::read_to_string(scenario_root.join(relative_path)) - .expect("declared evidence is readable UTF-8"); - // Complete logical records first, then the physical-line residue an - // intake must still surface so a fragment is visible without ever - // becoming a fact. - evidence.extend(normalize_ccm_artifact(artifact.clone(), &content)); - evidence.extend(normalize_physical_lines(&artifact, &content)); + 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")); } - artifacts.push(artifact); } - SccmNormalizedBundle { + let bundle = SccmClientIntakeBundle { artifacts, - evidence, - } + 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 { @@ -182,7 +324,7 @@ fn key_confidence_name(confidence: SccmDeploymentKeyConfidence) -> &'static str #[test] fn declared_transaction_outcomes_are_reproduced_for_every_scenario() { for scenario in SCENARIOS { - let analysis = analyze_client_deployment(&load_bundle(scenario)); + let analysis = analyze_scenario(scenario); let expected = expected(scenario); let declared = expected["transactions"] .as_array() @@ -243,7 +385,7 @@ fn declared_transaction_outcomes_are_reproduced_for_every_scenario() { #[test] fn declared_transaction_keys_are_bound_to_the_selected_version_profile() { for scenario in SCENARIOS { - let analysis = analyze_client_deployment(&load_bundle(scenario)); + let analysis = analyze_scenario(scenario); let expected = expected(scenario); let declared = expected["transactions"] .as_array() @@ -269,11 +411,11 @@ fn declared_transaction_keys_are_bound_to_the_selected_version_profile() { "{label}: key confidence" ); assert_eq!( - key.extraction_profile_id, SCCM_DEPLOYMENT_TEST_PROFILE_ID, + key.extraction_profile_id, SCCM_DEPLOYMENT_PROFILE_ID, "{label}: extraction profile" ); assert_eq!( - declared_key["extractionProfileId"], SCCM_DEPLOYMENT_TEST_PROFILE_ID, + declared_key["extractionProfileId"], SCCM_DEPLOYMENT_PROFILE_ID, "{label}: declared extraction profile" ); @@ -334,7 +476,7 @@ fn declared_transaction_keys_are_bound_to_the_selected_version_profile() { #[test] fn declared_transaction_evidence_spans_are_reproduced_exactly() { for scenario in SCENARIOS { - let analysis = analyze_client_deployment(&load_bundle(scenario)); + let analysis = analyze_scenario(scenario); let expected = expected(scenario); let declared = expected["transactions"] .as_array() @@ -342,7 +484,7 @@ fn declared_transaction_evidence_spans_are_reproduced_exactly() { for (produced, declared) in analysis.transactions.iter().zip(declared) { let label = format!("{scenario}/{}", produced.transaction_id); - let produced_spans = produced + let mut produced_spans = produced .evidence .iter() .map(|reference| { @@ -353,7 +495,7 @@ fn declared_transaction_evidence_spans_are_reproduced_exactly() { ) }) .collect::>(); - let declared_spans = declared["evidence"] + let mut declared_spans = declared["evidence"] .as_array() .expect("declared evidence is an array") .iter() @@ -368,6 +510,8 @@ fn declared_transaction_evidence_spans_are_reproduced_exactly() { ) }) .collect::>(); + produced_spans.sort(); + declared_spans.sort(); assert_eq!(produced_spans, declared_spans, "{label}: evidence spans"); } } @@ -376,7 +520,7 @@ fn declared_transaction_evidence_spans_are_reproduced_exactly() { #[test] fn counterpart_ready_facts_match_the_declared_content_request_boundary() { for scenario in SCENARIOS { - let analysis = analyze_client_deployment(&load_bundle(scenario)); + let analysis = analyze_scenario(scenario); let expected = expected(scenario); let declared = expected["transactions"] .as_array() @@ -405,7 +549,7 @@ fn counterpart_ready_facts_match_the_declared_content_request_boundary() { "{label}: counterpart phase" ); assert_eq!( - fact.extraction_profile_id, SCCM_DEPLOYMENT_TEST_PROFILE_ID, + fact.extraction_profile_id, SCCM_DEPLOYMENT_PROFILE_ID, "{label}: counterpart profile" ); assert_eq!( @@ -465,7 +609,7 @@ fn counterpart_ready_facts_match_the_declared_content_request_boundary() { #[test] fn no_scenario_claims_a_distribution_point_or_server_cause() { for scenario in SCENARIOS { - let analysis = analyze_client_deployment(&load_bundle(scenario)); + let analysis = analyze_scenario(scenario); let handoff = &analysis.correlation_handoff; assert!(!handoff.performed, "{scenario}: #333 is not performed here"); assert!( @@ -494,7 +638,6 @@ fn no_scenario_claims_a_distribution_point_or_server_cause() { fn coverage_state_name(state: &SccmCoverageState) -> &'static str { match state { SccmCoverageState::Captured => "captured", - SccmCoverageState::Partial => "partial", SccmCoverageState::Absent => "absent", SccmCoverageState::AccessDenied => "accessDenied", SccmCoverageState::Capped => "capped", @@ -553,7 +696,7 @@ fn declared_evidence_spans(value: &Value) -> Vec<(String, Option, Option SccmArtifact { SccmArtifact { - artifact_id: artifact_id.to_owned(), + artifact_id: format!("fixture-{artifact_id}"), display_name: basename.to_owned(), original_path: None, host: None, role: SccmRole::Client, - configmgr_version: Some("5.00.TEST.0000".to_owned()), - collected_at_utc: None, + 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()), @@ -934,18 +1072,64 @@ fn record(message: &str, time: &str, component: &str) -> String { ) } -fn bundle_from(sources: Vec<(SccmArtifact, String)>) -> SccmNormalizedBundle { +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 evidence = Vec::new(); - for (artifact, content) in sources { - evidence.extend(normalize_ccm_artifact(artifact.clone(), &content)); - evidence.extend(normalize_physical_lines(&artifact, &content)); - artifacts.push(artifact); + 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())?, + ); } - SccmNormalizedBundle { + let bundle = SccmClientIntakeBundle { artifacts, - evidence, - } + 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 { @@ -1012,71 +1196,24 @@ fn only_transaction( } #[test] -fn a_duplicate_client_artifact_identity_reports_coverage_only() { - let artifact = client_artifact("synthetic-intent", "AppIntentEval.log"); - let mut bundle = bundle_from(vec![(artifact.clone(), intent_content())]); - assert_eq!( - analyze_client_deployment(&bundle).transactions.len(), - 1, - "the same bundle without a collision must produce a transaction" - ); - - bundle.artifacts.push(artifact); - let analysis = analyze_client_deployment(&bundle); - assert!( - analysis.transactions.is_empty(), - "an ambiguous artifact identity cannot elect a source" - ); - assert!(analysis.findings.is_empty()); - assert!( - !analysis.coverage.is_empty(), - "coverage still reports what was collected" - ); -} - -#[test] -fn a_duplicate_evidence_identity_reports_coverage_only() { - let artifact = client_artifact("synthetic-intent", "AppIntentEval.log"); - let mut bundle = bundle_from(vec![(artifact, intent_content())]); - bundle.evidence.extend(bundle.evidence.clone()); - - let analysis = analyze_client_deployment(&bundle); - assert!( - analysis.transactions.is_empty(), - "a duplicated logical record cannot be an authority" - ); - assert!(analysis.findings.is_empty()); -} - -#[test] -fn an_artifact_from_another_role_never_decides_a_client_source() { +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(); - let mut forward = bundle_from(vec![(client.clone(), intent_content())]); - let mut reversed = forward.clone(); - forward.artifacts.push(management_point.clone()); - reversed.artifacts.insert(0, management_point); - - for (label, bundle) in [("client first", forward), ("client last", reversed)] { - let analysis = analyze_client_deployment(&bundle); - let transaction = only_transaction(&analysis); - assert_eq!( - transaction.key.assignment_id, ASSIGNMENT, - "{label}: client source was displaced by another role" - ); - assert_eq!( - phase_name(transaction.phase), - "locateContent", - "{label}: phase" - ); - } + 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 an_unorderable_terminal_record_downgrades_to_a_low_confidence_symptom() { +fn canonical_admission_rejects_unorderable_terminal_records() { let failing_transfer = format!( "{}{}", record( @@ -1094,33 +1231,23 @@ fn an_unorderable_terminal_record_downgrades_to_a_low_confidence_symptom() { "DataTransferService", ), ); - let bundle = bundle_from(vec![ - ( - client_artifact("synthetic-intent", "AppIntentEval.log"), - intent_content(), - ), - ( - client_artifact("synthetic-content", "CAS.log"), - content_content(), - ), - ( - client_artifact("synthetic-transfer", "DataTransferService.log"), - failing_transfer, - ), - ]); - - let analysis = analyze_client_deployment(&bundle); - let transaction = only_transaction(&analysis); - assert_eq!(phase_name(transaction.phase), "transfer"); - assert_eq!(state_name(transaction.state), "insufficientEvidence"); - assert_eq!(classification_name(transaction.classification), "symptom"); - assert_eq!(confidence_name(transaction.confidence), "low"); assert!( - analysis.findings.iter().any(|finding| finding - .finding - .finding_id - .starts_with("deployment-chronology-uncertain")), - "an unorderable chain must name the missing ordering evidence" + 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, + ), + ]) + .is_err(), + "timestamp provenance must fail before reducer authority exists" ); } @@ -1276,31 +1403,12 @@ fn two_configuration_items_under_one_assignment_never_form_a_transaction() { } #[test] -fn an_unprofiled_source_version_stays_a_source_local_observation() { +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()); - let bundle = bundle_from(vec![(artifact, intent_content())]); - - let analysis = analyze_client_deployment(&bundle); assert!( - analysis.transactions.is_empty(), - "an unprofiled version cannot produce facts" - ); - assert_eq!(analysis.source_local_observations.len(), 1); - let observation = &analysis.source_local_observations[0]; - assert!(observation.complete_logical_record); - assert!(!observation.correlation_eligible); - assert_eq!( - confidence_name(observation.confidence_ceiling), - "low", - "an unprofiled record stays capped at low confidence" - ); - assert!( - analysis - .extraction_profile - .validated_artifact_families - .is_empty(), - "a captured source the profile could not read is not a validated family" + try_bundle_from(vec![(artifact, intent_content())]).is_err(), + "an unregistered version must fail before reducer authority exists" ); } @@ -1358,7 +1466,7 @@ fn a_nonzero_exit_code_without_a_terminal_record_is_not_a_confirmed_failure() { #[test] fn the_public_projection_is_camel_case_and_carries_no_private_material() { for scenario in SCENARIOS { - let analysis = analyze_client_deployment(&load_bundle(scenario)); + let analysis = analyze_scenario(scenario); let json = serde_json::to_value(&analysis) .unwrap_or_else(|error| panic!("{scenario}: analysis serializes: {error}")); @@ -1451,7 +1559,7 @@ const OBSERVED_FAMILY_SCENARIOS: [&str; 7] = [ #[test] fn the_selected_extraction_profile_reports_what_the_bundle_validated() { for scenario in SCENARIOS { - let analysis = analyze_client_deployment(&load_bundle(scenario)); + let analysis = analyze_scenario(scenario); let expected = expected(scenario); let declared = &expected["extractionProfile"]; let profile = &analysis.extraction_profile; @@ -1534,57 +1642,35 @@ fn intent_record() -> String { } #[test] -fn a_physical_fragment_inside_a_captured_artifact_never_becomes_a_fact() { +fn canonical_admission_rejects_a_physical_fragment_inside_a_captured_artifact() { let content = format!( "{}Requirements satisfied assignmentId={ASSIGNMENT} ciId={CI}\n", intent_record() ); - let bundle = bundle_from(vec![( - client_artifact("synthetic-intent", "AppIntentEval.log"), - content, - )]); - - let analysis = analyze_client_deployment(&bundle); - let transaction = only_transaction(&analysis); - assert_eq!( - phase_name(transaction.phase), - "requirements", - "an unframed line cannot satisfy requirements" - ); - assert_eq!( - transaction.last_successful_phase.map(phase_name), - Some("intent"), - "a fragment cannot promote the last successful phase" - ); assert!( - analysis - .source_local_observations - .iter() - .any(|observation| observation.artifact_id == "synthetic-intent" - && !observation.complete_logical_record), - "the fragment must still be visible as a source-local observation" + try_bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + content, + )]) + .is_err(), + "incomplete CCM framing must fail before reducer authority exists" ); } #[test] -fn a_physical_fragment_never_confirms_a_terminal_failure() { +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() ); - let bundle = bundle_from(vec![( - client_artifact("synthetic-intent", "AppIntentEval.log"), - content, - )]); - - let analysis = analyze_client_deployment(&bundle); - let transaction = only_transaction(&analysis); - assert_ne!( - classification_name(transaction.classification), - "confirmedFailure", - "an unframed line cannot confirm a terminal failure" + assert!( + try_bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + content, + )]) + .is_err(), + "a terminal-looking fragment must fail before reducer authority exists" ); - assert_ne!(confidence_name(transaction.confidence), "high"); } #[test] @@ -1690,7 +1776,7 @@ fn a_punctuation_adjacent_duplicate_label_is_ambiguity_not_a_first_win() { } #[test] -fn a_chronology_finding_never_speaks_for_another_phase() { +fn canonical_admission_rejects_cross_phase_records_with_unorderable_timestamps() { let unorderable_requirements = record( &format!( "Requirements terminal failure assignmentId={OTHER_ASSIGNMENT} ciId={CI} requirementId=REQ-TEST-902 terminal=true" @@ -1713,95 +1799,36 @@ fn a_chronology_finding_never_speaks_for_another_phase() { "AppEnforce", ); - let bundle = bundle_from(vec![ - ( - client_artifact("synthetic-intent", "AppIntentEval.log"), - format!("{}{other_intent}", intent_content()), - ), - ( - rotated_artifact( - "synthetic-intent-rotated", - "AppIntentEval.log.1", - SccmRotation::Numbered(1), + assert!( + try_bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + format!("{}{other_intent}", intent_content()), ), - unorderable_requirements, - ), - ( - client_artifact("synthetic-content", "CAS.log"), - content_content(), - ), - ( - client_artifact("synthetic-transfer", "DataTransferService.log"), - transfer_content("05:00:03.000+000", "05:00:04.000+000"), - ), - ( - client_artifact("synthetic-enforce", "AppEnforce.log"), - unorderable_enforce, - ), - ]); - - let analysis = analyze_client_deployment(&bundle); - assert_eq!(analysis.transactions.len(), 2, "two keyed transactions"); - - let mut identifiers = analysis - .findings - .iter() - .map(|finding| finding.finding.finding_id.clone()) - .collect::>(); - let total = identifiers.len(); - identifiers.sort(); - identifiers.dedup(); - assert_eq!( - identifiers.len(), - total, - "finding identities must be unique" - ); - - let mut chronology = analysis - .findings - .iter() - .filter(|finding| { - finding - .finding - .finding_id - .starts_with("deployment-chronology-uncertain") - }) - .map(|finding| { ( - phase_name(finding.deployment_phase), - finding - .finding - .next_artifacts - .first() - .map(|request| request.logical_id.as_str()) - .unwrap_or("none"), - ) - }) - .collect::>(); - chronology.sort(); - assert_eq!( - chronology, - vec![("enforce", "appEnforce"), ("requirements", "appIntentEval")], - "each unorderable phase must request its own artifact" + 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, + ), + ]) + .is_err(), + "unorderable records must fail before reducer authority exists" ); - - for finding in &analysis.findings { - let phase = finding.deployment_phase; - for reference in &finding.finding.evidence { - let cited_by_this_phase = analysis.transactions.iter().any(|transaction| { - transaction.phase == phase - && transaction - .evidence - .iter() - .any(|cited| cited.artifact_id == reference.artifact_id) - }); - assert!( - cited_by_this_phase, - "{}: cites {} from a transaction at another phase", - finding.finding.finding_id, reference.artifact_id - ); - } - } } #[test] @@ -1857,7 +1884,10 @@ fn every_equally_terminal_record_is_cited_rather_than_one_elected() { .map(|terminal| terminal.reference.artifact_id.clone()) .collect::>(); cited.sort(); - let mut expected = vec![first_id.to_owned(), second_id.to_owned()]; + let mut expected = vec![ + "fixture-deployment-numbered-04".to_owned(), + "fixture-deployment-numbered-05".to_owned(), + ]; expected.sort(); assert_eq!( cited, expected, @@ -1882,7 +1912,7 @@ fn selection_state_name(state: SccmDeploymentProfileSelectionState) -> &'static #[test] fn the_extraction_profile_reports_its_selection_state() { for scenario in SCENARIOS { - let analysis = analyze_client_deployment(&load_bundle(scenario)); + let analysis = analyze_scenario(scenario); let expected = expected(scenario); assert_eq!( selection_state_name(analysis.extraction_profile.selection_state), @@ -1895,11 +1925,9 @@ fn the_extraction_profile_reports_its_selection_state() { let mut unprofiled = client_artifact("synthetic-intent", "AppIntentEval.log"); unprofiled.configmgr_version = Some("5.00.PROD.9128".to_owned()); - let analysis = analyze_client_deployment(&bundle_from(vec![(unprofiled, intent_content())])); - assert_eq!( - selection_state_name(analysis.extraction_profile.selection_state), - "unselected", - "no client source declares the profiled version" + assert!( + try_bundle_from(vec![(unprofiled, intent_content())]).is_err(), + "an unregistered profile cannot create deployment analysis authority" ); } @@ -1952,7 +1980,7 @@ fn a_repeated_identical_content_request_publishes_the_earliest_record_only() { .as_ref() .unwrap_or_else(|| panic!("{label}: an unambiguous repeated request is publishable")); assert_eq!( - fact.evidence.artifact_id, early_id, + fact.evidence.artifact_id, "fixture-deployment-numbered-02", "{label}: the citation must follow chronology, not the artifact name" ); assert_eq!( @@ -1963,7 +1991,7 @@ fn a_repeated_identical_content_request_publishes_the_earliest_record_only() { } #[test] -fn an_unorderable_repeated_content_request_is_never_published() { +fn canonical_admission_rejects_an_unorderable_repeated_content_request() { let located = |time: &str| { record( &format!( @@ -1973,29 +2001,26 @@ fn an_unorderable_repeated_content_request_is_never_published() { "CAS", ) }; - let bundle = bundle_from(vec![ - ( - client_artifact("synthetic-intent", "AppIntentEval.log"), - intent_content(), - ), - ( - client_artifact("synthetic-content-a", "CAS.log"), - located("05:00:02.000+000"), - ), - ( - rotated_artifact( - "synthetic-content-b", - "CAS.log.1", - SccmRotation::Numbered(1), - ), - located("05:00:09.000"), - ), - ]); - - let analysis = analyze_client_deployment(&bundle); - let transaction = only_transaction(&analysis); assert!( - transaction.counterpart_ready_fact.is_none(), - "two incomparable records cannot decide which instant is published" + 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"), + ), + ]) + .is_err(), + "incomparable record timestamps must fail before reducer authority exists" ); } diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment_fixture_contract.rs deleted file mode 100644 index db88db724..000000000 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment_fixture_contract.rs +++ /dev/null @@ -1,1490 +0,0 @@ -use cmtraceopen_parser::models::log_entry::LogFormat; -use regex::Regex; -use serde_json::Value; -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Component, Path, PathBuf}; - -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 STATE_CHAIN: [&str; 8] = [ - "intent", - "requirements", - "locateContent", - "transfer", - "cache", - "enforce", - "detect", - "report", -]; - -const DOCUMENTED_CORPUS_DIGEST: &str = - "27e0f8b6fab7bc584902718229824a45bdab9d1c9c78601e04f9d571e34c5c53"; - -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, - capture_states: BTreeMap, - digest: String, -} - -fn deployment_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/client/deployment") -} - -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 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 scenario_names() { - let scenario_root = deployment_root().join(&scenario); - let manifest = load_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; - 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: SCENARIOS.len(), - artifacts, - evidence_files, - evidence_bytes, - capture_states, - digest: hex_digest(&sha256(digest_rows.concat().as_bytes())), - } -} - -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 scenario_names() -> Vec { - let mut scenarios = std::fs::read_dir(deployment_root()) - .expect("deployment fixture root exists") - .map(|entry| { - entry - .expect("deployment 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 -} - -#[test] -fn missing_evidence_root_is_an_empty_corpus() { - let missing = deployment_root().join("__missing_evidence_root__"); - assert!(!missing.exists(), "test sentinel must stay absent"); - assert!( - walk_files(&missing).is_empty(), - "an all-noncapture scenario has no physical evidence directory" - ); -} - -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 json_string_array(value: &Value) -> Vec { - value - .as_array() - .expect("value is an array") - .iter() - .map(|item| item.as_str().expect("array item is 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 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 key_needle(field: &str, value: &Value) -> Result, String> { - if matches!( - field, - "keyProfileKind" | "confidence" | "extractionProfileId" - ) { - return Ok(None); - } - if let Some(value) = value.as_str() { - return Ok(Some(format!("{field}={value}"))); - } - if value.is_number() { - return Ok(Some(format!("{field}={value}"))); - } - Err(format!("transaction key field {field} is not scalar")) -} - -fn validate_semantic_contract( - scenario: &str, - scenario_root: &Path, - manifest: &Value, - expected: &Value, -) -> Result<(), String> { - let artifacts = manifest["artifacts"] - .as_array() - .ok_or_else(|| "manifest artifacts are not an array".to_owned())?; - let mut artifacts_by_id = BTreeMap::new(); - let mut artifact_paths = BTreeMap::new(); - let mut physical_by_logical = BTreeMap::>::new(); - - for artifact in artifacts { - let artifact_id = artifact["artifactId"] - .as_str() - .ok_or_else(|| "artifactId is not a string".to_owned())?; - if artifacts_by_id.insert(artifact_id, artifact).is_some() { - return Err(format!("duplicate artifactId {artifact_id}")); - } - artifact_effective_state(artifact)?; - let logical_id = artifact["designOnlyCatalog"]["entryId"] - .as_str() - .ok_or_else(|| format!("{artifact_id} has no design-only logical ID"))?; - physical_by_logical - .entry(logical_id.to_owned()) - .or_default() - .push(artifact); - - if let Some(relative_path) = artifact["relativePath"].as_str() { - if let Some(previous) = artifact_paths.insert(relative_path, artifact_id) { - return Err(format!( - "duplicate evidence path {relative_path} aliases {previous} and {artifact_id}" - )); - } - } - } - - let mut derived_coverage = BTreeMap::new(); - let mut derived_partial_ids = BTreeMap::>::new(); - for (logical_id, physical) in &physical_by_logical { - let states = physical - .iter() - .map(|artifact| artifact_effective_state(artifact)) - .collect::, _>>()?; - let state = combine_coverage_states(&states)?; - if state == "partial" { - let mut artifact_ids = physical - .iter() - .filter_map(|artifact| { - (artifact_effective_state(artifact).ok().as_deref() == Some("partial")) - .then(|| artifact["artifactId"].as_str().map(str::to_owned)) - .flatten() - }) - .collect::>(); - artifact_ids.sort(); - derived_partial_ids.insert(logical_id.clone(), artifact_ids); - } - derived_coverage.insert(logical_id.clone(), state); - } - - 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}")); - } - if state == "partial" { - let mut declared_ids = json_string_array(&coverage["artifactIds"]); - declared_ids.sort(); - let expected_ids = derived_partial_ids.get(logical_id).ok_or_else(|| { - format!("{logical_id} declares partial without partial artifacts") - })?; - if &declared_ids != expected_ids { - return Err(format!( - "{logical_id} partial coverage artifact IDs {declared_ids:?} != {expected_ids:?}" - )); - } - } - } - if declared_coverage != derived_coverage { - return Err(format!( - "coverage mismatch: declared {declared_coverage:?}, derived {derived_coverage:?}" - )); - } - - for transaction in expected["transactions"] - .as_array() - .ok_or_else(|| "transactions are not an array".to_owned())? - { - let transaction_id = transaction["transactionId"] - .as_str() - .ok_or_else(|| "transactionId is not a string".to_owned())?; - let evidence_refs = transaction["evidence"] - .as_array() - .ok_or_else(|| format!("{transaction_id} evidence is not an array"))?; - let mut cited_text = String::new(); - for evidence_ref in evidence_refs { - cited_text.push_str(&evidence_text( - scenario_root, - &artifacts_by_id, - evidence_ref, - )?); - cited_text.push('\n'); - } - for (field, value) in transaction["key"] - .as_object() - .ok_or_else(|| format!("{transaction_id} key is not an object"))? - { - let Some(needle) = key_needle(field, value)? else { - continue; - }; - if !cited_text.contains(&needle) { - return Err(format!( - "{transaction_id} key {field} is not bound to cited evidence ({needle})" - )); - } - } - - for artifact_id in json_string_array(&transaction["coverageGapArtifactIds"]) { - let artifact = artifacts_by_id - .get(artifact_id.as_str()) - .ok_or_else(|| format!("{transaction_id} coverage gap references {artifact_id}"))?; - if artifact_effective_state(artifact)? == "captured" { - return Err(format!( - "{transaction_id} coverage gap {artifact_id} is complete/captured" - )); - } - } - - let fact = &transaction["counterpartReadyFact"]; - if !fact.is_null() { - let fact_evidence = &fact["evidence"]; - let fact_text = evidence_text(scenario_root, &artifacts_by_id, fact_evidence)?; - for field in [ - "packageId", - "contentId", - "contentVersion", - "distributionPointHostHandle", - "requestId", - ] { - let needle = key_needle(field, &fact[field])? - .ok_or_else(|| format!("counterpart key {field} is metadata"))?; - if !fact_text.contains(&needle) { - return Err(format!( - "{transaction_id} counterpart {field} is not bound to cited evidence" - )); - } - } - - let timestamp = &fact["timestampProvenance"]; - let normalized = timestamp["normalizedUtc"] - .as_str() - .ok_or_else(|| format!("{transaction_id} counterpart timestamp is missing"))?; - let expected_millis = chrono::DateTime::parse_from_rfc3339(normalized) - .map_err(|error| format!("{transaction_id} counterpart timestamp: {error}"))? - .timestamp_millis(); - let expected_offset = timestamp["offsetMinutes"] - .as_i64() - .ok_or_else(|| format!("{transaction_id} counterpart offset is missing"))?; - let artifact_id = fact_evidence["artifactId"] - .as_str() - .ok_or_else(|| format!("{transaction_id} counterpart artifact is missing"))?; - let (entries, errors) = - cmtraceopen_parser::parser::ccm::parse_content(&fact_text, artifact_id, None); - let ccm_entries = entries - .iter() - .filter(|entry| entry.format == LogFormat::Ccm) - .collect::>(); - if errors != 0 || ccm_entries.len() != 1 { - return Err(format!( - "{transaction_id} counterpart citation is not one complete CCM record" - )); - } - let entry = ccm_entries[0]; - if entry.timestamp != Some(expected_millis) - || entry.timezone_offset.map(i64::from) != Some(expected_offset) - { - return Err(format!( - "{transaction_id} counterpart timestamp/offset is not bound to cited evidence" - )); - } - } - } - - let incomplete_physical_ids = artifacts - .iter() - .filter(|artifact| { - artifact["relativePath"].is_string() - && artifact["rotation"]["fragmentComplete"] == false - }) - .filter_map(|artifact| artifact["artifactId"].as_str().map(str::to_owned)) - .collect::>(); - let mut incomplete_observation_ids = BTreeSet::new(); - - for observation in expected["sourceLocalObservations"] - .as_array() - .ok_or_else(|| "sourceLocalObservations is not an array".to_owned())? - { - let observation_id = observation["observationId"] - .as_str() - .ok_or_else(|| "source-local observation has no ID".to_owned())?; - let key_confidence = observation["keyConfidence"] - .as_str() - .ok_or_else(|| format!("{observation_id} has no keyConfidence"))?; - let ceiling = observation["confidenceCeiling"] - .as_str() - .ok_or_else(|| format!("{observation_id} has no confidenceCeiling"))?; - let correlation_eligible = observation["correlationEligible"] - .as_bool() - .ok_or_else(|| format!("{observation_id} has no correlationEligible flag"))?; - if !matches!(key_confidence, "none" | "candidate") - || ceiling != "low" - || correlation_eligible - || observation["confidence"] - .as_str() - .is_some_and(|confidence| confidence != "low") - { - return Err(format!( - "source-local observation {observation_id} must stay Low and non-correlatable" - )); - } - - if observation["completeLogicalRecord"] == false { - let artifact_id = observation["artifactId"] - .as_str() - .ok_or_else(|| format!("{observation_id} has no artifactId"))?; - if observation["evidence"]["artifactId"] != artifact_id { - return Err(format!( - "source-local observation {observation_id} cites a different artifact" - )); - } - let artifact = artifacts_by_id - .get(artifact_id) - .ok_or_else(|| format!("{observation_id} references {artifact_id}"))?; - if artifact["rotation"]["fragmentComplete"] != false { - return Err(format!( - "source-local observation {observation_id} is incomplete but its artifact is complete" - )); - } - if !incomplete_observation_ids.insert(artifact_id.to_owned()) { - return Err(format!( - "source-local observation artifact {artifact_id} is duplicated" - )); - } - } - } - if incomplete_observation_ids != incomplete_physical_ids { - return Err(format!( - "incomplete source-local observations {incomplete_observation_ids:?} do not match physical fragments {incomplete_physical_ids:?}" - )); - } - - if expected["scenario"] != scenario { - return Err(format!( - "expected scenario {} does not match {scenario}", - expected["scenario"] - )); - } - Ok(()) -} - -#[test] -fn deployment_corpus_inventory_and_path_artifact_digest_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(), 2); - capture_states.insert("accessDenied".to_owned(), 1); - capture_states.insert("capped".to_owned(), 1); - capture_states.insert("captured".to_owned(), 32); - assert_eq!( - corpus_inventory(), - CorpusInventory { - scenarios: 12, - artifacts: 36, - evidence_files: 33, - evidence_bytes: 16_840, - capture_states, - digest: DOCUMENTED_CORPUS_DIGEST.to_owned(), - } - ); -} - -#[test] -fn ccm_records_and_physical_rotation_boundaries_are_pinned() { - for scenario in SCENARIOS { - let scenario_root = deployment_root().join(scenario); - let manifest = load_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; - }; - if artifact["kind"] != "ccmLog" { - continue; - } - - let content = std::fs::read_to_string(scenario_root.join(relative_path)) - .expect("CCM evidence is UTF-8"); - let (entries, errors) = - cmtraceopen_parser::parser::ccm::parse_content(&content, relative_path, None); - let complete = artifact["rotation"]["fragmentComplete"] == true; - if complete { - assert_eq!( - errors, 0, - "{scenario}/{relative_path}: complete CCM parse errors" - ); - assert!( - !entries.is_empty() - && entries.iter().all(|entry| entry.format == LogFormat::Ccm), - "{scenario}/{relative_path}: complete evidence must contain only logical CCM records" - ); - } else { - assert!( - entries.iter().all(|entry| entry.format != LogFormat::Ccm), - "{scenario}/{relative_path}: incomplete physical evidence formed a CCM record" - ); - } - } - } - - let rotation_root = deployment_root().join("rotation-boundary"); - let manifest = load_json(&rotation_root.join("manifest.json")); - let artifacts = manifest["artifacts"] - .as_array() - .expect("manifest artifacts are an array"); - let current = artifacts - .iter() - .find(|artifact| artifact["artifactId"] == "deployment-rotation-boundary-content-current") - .expect("rotation current artifact"); - let archived = artifacts - .iter() - .find(|artifact| artifact["artifactId"] == "deployment-rotation-boundary-content-lo") - .expect("rotation archived artifact"); - - assert_eq!(current["originalBasename"], "CAS.log"); - assert_eq!(current["rotation"]["kind"], "current"); - assert_eq!(current["rotation"]["fragmentComplete"], false); - assert_eq!(archived["originalBasename"], "CAS.lo_"); - assert_eq!(archived["rotation"]["kind"], "lo"); - assert_eq!(archived["rotation"]["fragmentComplete"], false); - assert_eq!(current["pathFingerprint"], archived["pathFingerprint"]); - assert_ne!(current["relativePath"], archived["relativePath"]); - - let archived_content = std::fs::read_to_string( - rotation_root.join( - archived["relativePath"] - .as_str() - .expect("archived relative path"), - ), - ) - .expect("archived rotation fixture"); - let current_content = std::fs::read_to_string( - rotation_root.join( - current["relativePath"] - .as_str() - .expect("current relative path"), - ), - ) - .expect("current rotation fixture"); - let combined = format!("{archived_content}{current_content}"); - let (combined_entries, combined_errors) = cmtraceopen_parser::parser::ccm::parse_content( - &combined, - "CAS.combined-for-test.log", - None, - ); - assert_eq!(combined_errors, 0, "controlled join forms one CCM record"); - assert_eq!(combined_entries.len(), 1, "controlled join record count"); - assert_eq!(combined_entries[0].format, LogFormat::Ccm); -} - -#[test] -fn manifest_coverage_citations_and_observation_ceilings_are_bound() { - for scenario in SCENARIOS { - let scenario_root = deployment_root().join(scenario); - let manifest = load_json(&scenario_root.join("manifest.json")); - let expected = load_json(&scenario_root.join("expected.json")); - validate_semantic_contract(scenario, &scenario_root, &manifest, &expected) - .unwrap_or_else(|error| panic!("{scenario}: {error}")); - } -} - -#[test] -fn adversarial_self_declared_contract_mutations_fail_closed() { - let scenario_root = deployment_root().join("location-missing"); - let manifest = load_json(&scenario_root.join("manifest.json")); - let mut expected = load_json(&scenario_root.join("expected.json")); - expected["coverage"][1]["state"] = Value::String("captured".to_owned()); - let error = - validate_semantic_contract("location-missing", &scenario_root, &manifest, &expected) - .expect_err("absent manifest coverage cannot be self-declared captured"); - assert!(error.contains("coverage"), "{error}"); - - let scenario_root = deployment_root().join("success"); - let manifest = load_json(&scenario_root.join("manifest.json")); - let mut expected = load_json(&scenario_root.join("expected.json")); - expected["transactions"][0]["key"]["packageId"] = Value::String("LAB99999".to_owned()); - let error = validate_semantic_contract("success", &scenario_root, &manifest, &expected) - .expect_err("a transaction key must be present in its cited evidence"); - assert!(error.contains("packageId"), "{error}"); - - let mut expected = load_json(&scenario_root.join("expected.json")); - expected["transactions"][0]["counterpartReadyFact"]["timestampProvenance"]["normalizedUtc"] = - Value::String("2026-07-30T03:00:03Z".to_owned()); - let error = validate_semantic_contract("success", &scenario_root, &manifest, &expected) - .expect_err("counterpart timestamp must bind to the cited CCM record"); - assert!(error.contains("timestamp"), "{error}"); - - let mut duplicate_path_manifest = manifest.clone(); - duplicate_path_manifest["artifacts"][1]["relativePath"] = - duplicate_path_manifest["artifacts"][0]["relativePath"].clone(); - let expected = load_json(&scenario_root.join("expected.json")); - let error = validate_semantic_contract( - "success", - &scenario_root, - &duplicate_path_manifest, - &expected, - ) - .expect_err("two artifact IDs cannot alias one evidence path"); - assert!(error.contains("duplicate evidence path"), "{error}"); - - let scenario_root = deployment_root().join("rotation-boundary"); - let manifest = load_json(&scenario_root.join("manifest.json")); - let mut expected = load_json(&scenario_root.join("expected.json")); - expected["sourceLocalObservations"][0]["keyConfidence"] = Value::String("exact".to_owned()); - let error = - validate_semantic_contract("rotation-boundary", &scenario_root, &manifest, &expected) - .expect_err("incomplete candidates cannot claim exact keys"); - assert!(error.contains("source-local observation"), "{error}"); - - let mut expected = load_json(&scenario_root.join("expected.json")); - expected["sourceLocalObservations"][0]["confidenceCeiling"] = Value::String("high".to_owned()); - let error = - validate_semantic_contract("rotation-boundary", &scenario_root, &manifest, &expected) - .expect_err("incomplete candidates must stay low"); - assert!(error.contains("source-local observation"), "{error}"); - - let mut expected = load_json(&scenario_root.join("expected.json")); - expected["sourceLocalObservations"][0]["correlationEligible"] = Value::Bool(true); - let error = - validate_semantic_contract("rotation-boundary", &scenario_root, &manifest, &expected) - .expect_err("incomplete candidates must stay non-correlatable"); - assert!(error.contains("source-local observation"), "{error}"); -} - -#[test] -fn deployment_fixture_matrix_is_exact_safe_and_deterministic() { - assert_eq!( - scenario_names(), - SCENARIOS.map(str::to_owned), - "the #322 scenario matrix changed" - ); - - let privacy_patterns = [ - ( - "user profile path", - Regex::new(r"(?i)[A-Z]:\\Users\\").expect("user path regex"), - ), - ( - "Windows SID", - Regex::new(r"\bS-1-\d+(?:-\d+){2,}\b").expect("SID regex"), - ), - ( - "email address", - Regex::new(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b").expect("email regex"), - ), - ]; - - for scenario in SCENARIOS { - let scenario_root = deployment_root().join(scenario); - let manifest = load_json(&scenario_root.join("manifest.json")); - let expected = load_json(&scenario_root.join("expected.json")); - - assert_eq!( - manifest["scenario"], scenario, - "{scenario}: manifest scenario" - ); - assert_eq!( - manifest["proposalOnly"], true, - "{scenario}: proposal boundary" - ); - assert_eq!( - manifest["syntheticFixture"], true, - "{scenario}: synthetic boundary" - ); - assert_eq!(manifest["bundle"]["role"], "client", "{scenario}: role"); - assert_eq!( - manifest["bundle"]["workflow"], "deployment", - "{scenario}: workflow" - ); - assert_eq!( - manifest["bundle"]["siteCode"], "LAB", - "{scenario}: exact synthetic site code" - ); - - assert_eq!( - expected["contractState"], "proposedPending318And319", - "{scenario}: dependency boundary" - ); - assert_eq!(expected["workflow"], "deployment", "{scenario}: workflow"); - assert_eq!( - expected["scenario"], scenario, - "{scenario}: expected scenario" - ); - assert_eq!( - json_string_array(&expected["stateChain"]), - STATE_CHAIN.map(str::to_owned), - "{scenario}: phase chain" - ); - assert_eq!( - expected["analysisContract"]["independentReducer"], true, - "{scenario}: independent reducer" - ); - assert_eq!( - expected["analysisContract"]["consumesPolicyReducerOutput"], false, - "{scenario}: deployment must not consume policy output" - ); - assert_eq!( - expected["analysisContract"]["policyCoverageRequired"], false, - "{scenario}: missing policy coverage must not block deployment facts" - ); - assert_eq!( - expected["analysisContract"]["crossSideCorrelationPerformed"], false, - "{scenario}: no cross-side correlation" - ); - assert_eq!( - expected["reorderedInputDeterministic"], true, - "{scenario}: deterministic input reordering contract" - ); - - let artifacts = manifest["artifacts"] - .as_array() - .expect("manifest artifacts are an array"); - let mut artifacts_by_id = BTreeMap::new(); - let mut referenced_files = BTreeSet::new(); - - for artifact in artifacts { - let artifact_id = artifact["artifactId"] - .as_str() - .expect("artifactId is a string"); - assert!( - artifacts_by_id.insert(artifact_id, artifact).is_none(), - "{scenario}: duplicate artifactId {artifact_id}" - ); - assert_eq!(artifact["role"], "client", "{scenario}/{artifact_id}: role"); - - let state = artifact["captureState"] - .as_str() - .expect("captureState is a string"); - let captured = matches!(state, "captured" | "capped"); - if captured { - assert_eq!( - artifact["encoding"], "utf-8", - "{scenario}/{artifact_id}: encoding" - ); - let relative_path = artifact["relativePath"] - .as_str() - .expect("captured artifact has a relativePath"); - let relative = Path::new(relative_path); - assert!( - !relative.is_absolute() - && relative - .components() - .all(|component| matches!(component, Component::Normal(_))), - "{scenario}/{artifact_id}: unsafe relativePath {relative_path}" - ); - assert_eq!( - relative.components().next(), - Some(Component::Normal(std::ffi::OsStr::new("evidence"))), - "{scenario}/{artifact_id}: evidence path root" - ); - - let fixture_path = scenario_root.join(relative); - assert!( - fixture_path.is_file(), - "{scenario}/{artifact_id}: missing {}", - fixture_path.display() - ); - let actual_bytes = std::fs::metadata(&fixture_path) - .expect("evidence metadata is readable") - .len(); - assert_eq!( - artifact["bytesCopied"].as_u64(), - Some(actual_bytes), - "{scenario}/{artifact_id}: exact bytes" - ); - let contents = - std::fs::read_to_string(&fixture_path).expect("evidence fixture is UTF-8"); - if artifact["rotation"]["fragmentComplete"] == true { - assert!( - contents.contains("SYNTHETIC FIXTURE"), - "{scenario}/{artifact_id}: complete evidence needs a marker" - ); - } - let canonical_path = fixture_path - .canonicalize() - .expect("evidence fixture canonicalizes"); - assert!( - referenced_files.insert(canonical_path), - "{scenario}/{artifact_id}: duplicate canonical evidence path" - ); - } else { - assert_eq!( - artifact["bytesCopied"], 0, - "{scenario}/{artifact_id}: noncapture bytes" - ); - assert!( - artifact["relativePath"].is_null(), - "{scenario}/{artifact_id}: noncapture relativePath" - ); - assert!( - artifact["encoding"].is_null(), - "{scenario}/{artifact_id}: noncapture encoding" - ); - assert!( - artifact["collectionLimit"].is_null(), - "{scenario}/{artifact_id}: noncapture collectionLimit" - ); - } - } - - let evidence_files = walk_files(&scenario_root.join("evidence")) - .into_iter() - .map(|path| path.canonicalize().expect("evidence path canonicalizes")) - .collect::>(); - assert_eq!( - evidence_files, referenced_files, - "{scenario}: evidence files must be referenced exactly once" - ); - - let provenance = expected["artifactProvenance"] - .as_array() - .expect("artifactProvenance is an array"); - let provenance_ids = sorted_ids(&expected["artifactProvenance"], "artifactId"); - let mut sorted_provenance_ids = provenance_ids.clone(); - sorted_provenance_ids.sort(); - assert_eq!( - provenance_ids, sorted_provenance_ids, - "{scenario}: provenance order" - ); - let mut physical_evidence_ids = artifacts - .iter() - .filter(|artifact| artifact["relativePath"].is_string()) - .map(|artifact| { - artifact["artifactId"] - .as_str() - .expect("artifactId is a string") - .to_owned() - }) - .collect::>(); - physical_evidence_ids.sort(); - assert_eq!( - provenance_ids, physical_evidence_ids, - "{scenario}: provenance must cover every physical evidence artifact exactly once" - ); - for item in provenance { - let artifact_id = item["artifactId"] - .as_str() - .expect("provenance artifactId is a string"); - let artifact = artifacts_by_id - .get(artifact_id) - .unwrap_or_else(|| panic!("{scenario}: unknown provenance {artifact_id}")); - assert_eq!( - item["bytesCopied"], artifact["bytesCopied"], - "{scenario}/{artifact_id}: expected bytes mirror manifest" - ); - assert_eq!( - item["encoding"], artifact["encoding"], - "{scenario}/{artifact_id}: expected encoding mirrors manifest" - ); - assert_eq!( - item["byteLimit"], artifact["collectionLimit"]["byteLimit"], - "{scenario}/{artifact_id}: expected byte limit mirrors manifest" - ); - assert_eq!( - item["limitApplied"], artifact["collectionLimit"]["limitApplied"], - "{scenario}/{artifact_id}: expected cap flag mirrors manifest" - ); - } - - let transaction_ids = sorted_ids(&expected["transactions"], "transactionId"); - let mut sorted_transaction_ids = transaction_ids.clone(); - sorted_transaction_ids.sort(); - assert_eq!( - transaction_ids, sorted_transaction_ids, - "{scenario}: transaction order" - ); - let finding_ids = sorted_ids(&expected["findings"], "findingId"); - let mut sorted_finding_ids = finding_ids.clone(); - sorted_finding_ids.sort(); - assert_eq!(finding_ids, sorted_finding_ids, "{scenario}: finding order"); - - let mut evidence_refs = Vec::new(); - collect_evidence_refs(&expected, &mut evidence_refs); - for (artifact_id, start_line, end_line) in evidence_refs { - let artifact = artifacts_by_id - .get(artifact_id.as_str()) - .unwrap_or_else(|| panic!("{scenario}: unknown evidence artifact {artifact_id}")); - let relative_path = artifact["relativePath"] - .as_str() - .unwrap_or_else(|| panic!("{scenario}/{artifact_id}: evidence is not captured")); - let contents = std::fs::read_to_string(scenario_root.join(relative_path)) - .expect("evidence fixture is readable"); - let line_count = contents.lines().count() as u64; - assert!( - start_line >= 1 && end_line >= start_line && end_line <= line_count, - "{scenario}/{artifact_id}: invalid evidence lines {start_line}-{end_line}/{line_count}" - ); - } - - for transaction in expected["transactions"] - .as_array() - .expect("transactions are an array") - { - if let Some(next_artifact) = transaction["nextArtifact"].as_object() { - let logical_id = next_artifact["logicalArtifactId"] - .as_str() - .expect("next artifact logical ID is a string"); - assert!( - matches!( - logical_id, - "client-app-intent" - | "client-app-enforce" - | "client-content" - | "client-policy-state" - ), - "{scenario}: unbounded next artifact {logical_id}" - ); - assert_ne!( - logical_id, "client-policy-agent", - "{scenario}: deployment must not depend on policy output" - ); - } - } - - for file in walk_files(&scenario_root) { - let contents = std::fs::read_to_string(&file).expect("fixture file is UTF-8"); - for forbidden in [ - "CONTOSO", - ".log.lo_", - "C:\\Users\\", - "Authorization:", - "Bearer ", - "client_secret", - "S-1-", - ] { - assert!( - !contents.contains(forbidden), - "{} contains forbidden fixture material {forbidden}", - file.display() - ); - } - for (label, pattern) in &privacy_patterns { - assert!( - !pattern.is_match(&contents), - "{} contains possible {label}", - file.display() - ); - } - } - } -} - -#[test] -fn deployment_outcomes_keep_phases_and_coverage_conservative() { - let cases = [ - ( - "bits-transfer-failure", - 1, - "transfer", - "failed", - "locateContent", - "confirmedFailure", - "high", - None, - ), - ( - "cache-failure", - 1, - "cache", - "failed", - "transfer", - "confirmedFailure", - "high", - None, - ), - ( - "dependency-failure", - 1, - "requirements", - "failed", - "intent", - "confirmedFailure", - "high", - None, - ), - ( - "detection-false-negative", - 1, - "detect", - "detectionMismatch", - "enforce", - "symptom", - "medium", - None, - ), - ( - "dp-content-missing", - 1, - "locateContent", - "insufficientEvidence", - "requirements", - "insufficientEvidence", - "low", - Some("client-content"), - ), - ( - "enforcement-exit", - 1, - "enforce", - "failed", - "cache", - "confirmedFailure", - "high", - None, - ), - ( - "incomplete", - 2, - "locateContent", - "insufficientEvidence", - "requirements", - "insufficientEvidence", - "low", - Some("client-content"), - ), - ( - "location-missing", - 1, - "locateContent", - "insufficientEvidence", - "requirements", - "insufficientEvidence", - "low", - Some("client-content"), - ), - ( - "not-targeted", - 1, - "intent", - "notTargeted", - "", - "notTargeted", - "high", - None, - ), - ( - "requirements-failure", - 1, - "requirements", - "failed", - "intent", - "confirmedFailure", - "high", - None, - ), - ( - "rotation-boundary", - 1, - "locateContent", - "insufficientEvidence", - "requirements", - "insufficientEvidence", - "low", - Some("client-content"), - ), - ( - "success", - 1, - "report", - "succeeded", - "report", - "success", - "high", - None, - ), - ]; - - for ( - scenario, - transaction_count, - phase, - state, - last_success, - classification, - confidence, - next_artifact, - ) in cases - { - let expected = load_json(&deployment_root().join(scenario).join("expected.json")); - let transactions = expected["transactions"] - .as_array() - .expect("transactions are an array"); - assert_eq!( - transactions.len(), - transaction_count, - "{scenario}: transaction count" - ); - for transaction in transactions { - assert_eq!(transaction["phase"], phase, "{scenario}: phase"); - assert_eq!(transaction["state"], state, "{scenario}: state"); - if last_success.is_empty() { - assert!( - transaction["lastSuccessfulPhase"].is_null(), - "{scenario}: last successful phase" - ); - } else { - assert_eq!( - transaction["lastSuccessfulPhase"], last_success, - "{scenario}: last successful phase" - ); - } - assert_eq!( - transaction["classification"], classification, - "{scenario}: classification" - ); - assert_eq!( - transaction["confidence"], confidence, - "{scenario}: confidence" - ); - assert_eq!( - transaction["confidenceCeiling"], confidence, - "{scenario}: confidence ceiling" - ); - match next_artifact { - Some(logical_id) => assert_eq!( - transaction["nextArtifact"]["logicalArtifactId"], logical_id, - "{scenario}: next artifact" - ), - None => assert!( - transaction["nextArtifact"].is_null(), - "{scenario}: unexpected next artifact" - ), - } - } - } -} - -#[test] -fn counterpart_facts_require_exact_keys_and_adversarial_inputs_stay_unlinked() { - let counterpart_scenarios = [ - "bits-transfer-failure", - "cache-failure", - "detection-false-negative", - "dp-content-missing", - "enforcement-exit", - "success", - ]; - - for scenario in SCENARIOS { - let expected = load_json(&deployment_root().join(scenario).join("expected.json")); - let should_emit = counterpart_scenarios.contains(&scenario); - let mut emitted = 0; - - for transaction in expected["transactions"] - .as_array() - .expect("transactions are an array") - { - let fact = &transaction["counterpartReadyFact"]; - if fact.is_null() { - continue; - } - emitted += 1; - assert!(should_emit, "{scenario}: unexpected counterpart-ready fact"); - assert_eq!( - transaction["key"]["confidence"], "exact", - "{scenario}: counterpart transaction key confidence" - ); - assert_eq!( - transaction["key"]["extractionProfileId"], "deployment-client-5.00.test-v1", - "{scenario}: counterpart transaction profile" - ); - assert_eq!( - fact["extractionProfileId"], transaction["key"]["extractionProfileId"], - "{scenario}: counterpart profile" - ); - for field in [ - "packageId", - "contentId", - "contentVersion", - "distributionPointHostHandle", - "requestId", - ] { - assert_eq!( - fact[field], transaction["key"][field], - "{scenario}: counterpart {field}" - ); - } - assert_eq!( - fact["timestampProvenance"]["kind"], "explicitOffset", - "{scenario}: timestamp provenance" - ); - assert_eq!( - fact["timestampProvenance"]["offsetMinutes"], 0, - "{scenario}: timestamp offset" - ); - assert_eq!( - fact["phase"], "locateContent", - "{scenario}: counterpart phase" - ); - } - - assert_eq!( - emitted, - usize::from(should_emit), - "{scenario}: counterpart-ready fact count" - ); - assert_eq!( - expected["correlationHandoff"]["performed"], false, - "{scenario}: #333 is not performed" - ); - assert_eq!( - expected["correlationHandoff"]["timeOnlyEligible"], false, - "{scenario}: time-only cannot correlate" - ); - assert_eq!( - expected["correlationHandoff"]["topologyCompatibilityEvaluated"], false, - "{scenario}: topology belongs to #333" - ); - assert_eq!( - expected["correlationHandoff"]["serverCauseClaimed"], false, - "{scenario}: no DP/server cause" - ); - } - - let incomplete = load_json(&deployment_root().join("incomplete").join("expected.json")); - let transactions = incomplete["transactions"] - .as_array() - .expect("incomplete transactions are an array"); - assert_eq!(transactions.len(), 2); - assert_ne!( - transactions[0]["transactionId"], transactions[1]["transactionId"], - "same-minute exact keys must stay separate" - ); - assert_eq!( - incomplete["adversarialControls"]["sameMinuteDifferentExactKeysStaySeparate"], - true - ); - - let enforcement = load_json( - &deployment_root() - .join("enforcement-exit") - .join("expected.json"), - ); - let observations = enforcement["sourceLocalObservations"] - .as_array() - .expect("source-local observations are an array"); - assert_eq!(observations.len(), 1); - assert_eq!(observations[0]["keyConfidence"], "none"); - assert_eq!(observations[0]["confidenceCeiling"], "low"); - assert_eq!(observations[0]["correlationEligible"], false); - - let success = load_json(&deployment_root().join("success").join("expected.json")); - assert!( - success["coverage"] - .as_array() - .expect("success coverage is an array") - .iter() - .any(|coverage| { - coverage["logicalArtifactId"] == "client-policy-agent" - && coverage["state"] == "absent" - }), - "success must prove deployment independence from absent policy coverage" - ); - assert_eq!(success["transactions"][0]["state"], "succeeded"); -} diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index afcc09691..edd904b5d 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -2,12 +2,11 @@ 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, normalize_physical_lines, SccmArtifact, - SccmArtifactFamily, SccmArtifactRequest, SccmConfidence, SccmCorrelationKey, - SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, SccmEvidenceRef, - SccmExtractionGapKind, SccmExtractionProfile, SccmExtractionProfileMaturity, SccmFinding, - SccmFindingBuilder, SccmFindingClass, SccmFindingCoverageGap, SccmFindingValidationError, - SccmKeyConfidence, SccmKeyExtractionResult, SccmPhase, SccmRecordCompleteness, SccmRole, + 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, @@ -32,7 +31,6 @@ fn client_policy_artifact() -> SccmArtifact { fn evidence_with_message(message: &str) -> SccmEvidence { SccmEvidence { evidence_id: "client-policy-agent:1-1".into(), - completeness: SccmRecordCompleteness::LogicalRecord, reference: SccmEvidenceRef { artifact_id: "client-policy-agent".into(), entry_id: "client-policy-agent:1-1".into(), @@ -6963,60 +6961,6 @@ fn catalog_rotation_grammar_preserves_unknown_suffix_and_initialism() { assert!(!class.supported_for_diagnosis); } -#[test] -fn physical_line_normalization_emits_exactly_the_lines_no_record_covers() { - // Line 1 is a fragment, lines 2-3 are one multi-line record, line 4 is a - // fragment, line 5 is blank, line 6 is a record, line 7 is a fragment. - let content = concat!( - "leading fragment\n", - "\n", - "interior fragment\n", - " \n", - "\n", - "trailing fragment\n", - ); - - let artifact = client_policy_artifact(); - let records = normalize_ccm_artifact(artifact.clone(), content); - let covered = records - .iter() - .flat_map(|record| { - let start = record.reference.line_start.expect("record line start"); - let end = record.reference.line_end.expect("record line end"); - start..=end - }) - .collect::>(); - assert!( - covered.contains(&2) && covered.contains(&3), - "the fixture must contain a multi-line record so nested spans are exercised" - ); - - let fragments = normalize_physical_lines(&artifact, content); - assert_eq!( - fragments - .iter() - .map(|fragment| fragment.reference.line_start.expect("fragment line start")) - .collect::>(), - vec![1, 4, 7], - "only uncovered, non-blank lines are fragments" - ); - for fragment in &fragments { - assert_eq!( - fragment.completeness, - SccmRecordCompleteness::PhysicalFragment - ); - assert_eq!( - fragment.reference.line_start, fragment.reference.line_end, - "a fragment spans exactly one physical line" - ); - assert!( - !covered.contains(&fragment.reference.line_start.expect("fragment line start")), - "a line a record already covers is never a fragment" - ); - } -} - #[test] fn catalog_requires_exact_producer_roles_for_server_workflow_sources() { let cases = [ @@ -7251,14 +7195,6 @@ fn expected_catalog_tuples() -> Vec { true, true, ), - ( - "StateMessage.log", - SccmRole::Client, - "stateMessage", - SccmArtifactFamily::ClientPolicy, - true, - true, - ), ( "CAS.log", SccmRole::Client, From eb1b8ba50fee8fb7a6630f8d29b951c3ba88d0c4 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 04:12:09 -0400 Subject: [PATCH 377/422] fix(sccm): fail closed on deployment ambiguity --- .../src/sccm/client/deployment.rs | 291 +++++++++++++++--- .../dp-content-missing/expected.json | 2 +- .../rotation-boundary/expected.json | 6 +- .../tests/sccm_client_deployment.rs | 144 ++++++++- 4 files changed, 381 insertions(+), 62 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs index 84983daee..7f540369d 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs @@ -55,6 +55,8 @@ const REASON_DETECT: &str = "capture the complete client detection outcome for t 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", @@ -238,6 +240,7 @@ pub struct SccmDeploymentTransaction { pub struct SccmDeploymentCoverage { pub logical_artifact_id: String, pub state: SccmCoverageState, + pub capture_complete: bool, pub artifact_ids: Vec, } @@ -329,8 +332,16 @@ pub fn analyze_client_deployment( admitted: &SccmClientAdmittedEvidence, ) -> Result { admitted.verify_integrity()?; - let artifacts = admitted - .source_artifacts()? + 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(), @@ -341,17 +352,11 @@ pub fn analyze_client_deployment( configmgr_version: None, collected_at_utc: None, rotation: source.rotation.clone(), - coverage: if source.coverage == SccmCoverageState::Captured - && source.fragment_complete != Some(true) - { - SccmCoverageState::Capped - } else { - source.coverage.clone() - }, + coverage: source.coverage.clone(), encoding: None, }) .collect::>(); - let coverage = coverage_rows(&artifacts); + let coverage = coverage_rows(&artifacts, &incomplete_artifact_ids); let artifacts_by_id = artifacts .iter() .map(|artifact| (artifact.artifact_id.as_str(), artifact)) @@ -361,6 +366,9 @@ pub fn analyze_client_deployment( 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)) @@ -391,22 +399,30 @@ pub fn analyze_client_deployment( } } - let admitted_artifact_ids = facts + 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 = admitted_artifact_ids + 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, &admitted_artifact_ids); + 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( @@ -532,7 +548,10 @@ fn extraction_profile( // Coverage // --------------------------------------------------------------------------- -fn coverage_rows(artifacts: &[SccmArtifact]) -> Vec { +fn coverage_rows( + artifacts: &[SccmArtifact], + incomplete_artifact_ids: &BTreeSet<&str>, +) -> Vec { let mut grouped = BTreeMap::>::new(); for artifact in artifacts { grouped @@ -550,15 +569,24 @@ fn coverage_rows(artifacts: &[SccmArtifact]) -> Vec { .collect::>(); let mut artifact_ids = artifacts .iter() - .filter(|artifact| artifact.coverage != SccmCoverageState::Captured) + .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: combine_coverage(&states), + state, + capture_complete, artifact_ids, } }) @@ -1133,7 +1161,6 @@ struct FindingSeed { evidence: Vec, terminal_evidence: Vec, coverage_gap_artifact_ids: Vec, - coverage_gap_group: Option<&'static str>, request_phase: Option, } @@ -1168,9 +1195,6 @@ fn build_transaction( }, terminal_evidence: outcome.terminal_evidence.clone(), coverage_gap_artifact_ids: outcome.coverage_gap_artifact_ids.clone(), - coverage_gap_group: (outcome.classification - == SccmDeploymentClassification::InsufficientEvidence) - .then(|| outcome.phase.artifact_group()), request_phase: outcome.next_artifact.as_ref().map(|_| outcome.phase), }); @@ -1202,7 +1226,24 @@ fn unique_value(values: impl Iterator) -> Option { .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)); @@ -1244,7 +1285,11 @@ fn build_key(assignment_id: &str, ci_id: &str, facts: &[&DeploymentFact]) -> Scc }) .filter_map(|fact| fact.exit_code.clone()), ), - confidence: SccmDeploymentKeyConfidence::Exact, + confidence: if ambiguous_topology { + SccmDeploymentKeyConfidence::Candidate + } else { + SccmDeploymentKeyConfidence::Exact + }, extraction_profile_id: SCCM_DEPLOYMENT_PROFILE_ID.to_owned(), } } @@ -1368,6 +1413,29 @@ fn unrecovered_failures<'a>( .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) + ) + }) + }) +} + fn facts_of_kind<'a>( facts: &[&'a DeploymentFact], kind: DeploymentFactKind, @@ -1470,6 +1538,28 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage 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, + ); + } + if let Some(not_applicable) = first_fact(facts, DeploymentFactKind::IntentNotApplicable) { return conclude( &[not_applicable], @@ -1488,6 +1578,23 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage 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 = facts_of_kind(facts, DeploymentFactKind::RequirementsFailed); let dependency_failures = facts_of_kind(facts, DeploymentFactKind::DependencyFailed); if !requirement_failures.is_empty() || !dependency_failures.is_empty() { @@ -1531,9 +1638,16 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage let reason = if first_fact(facts, DeploymentFactKind::ContentRequested).is_some() { REASON_LOCATION_RESPONSE_MISSING } else { - match coverage_for_group(coverage, GROUP_CONTENT).map(|row| &row.state) { - Some(SccmCoverageState::Capped) => REASON_LOCATION_ROTATION, - Some(SccmCoverageState::AccessDenied) => REASON_LOCATION_ACCESS_DENIED, + 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, } }; @@ -1542,6 +1656,19 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage 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, @@ -1589,6 +1716,19 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage 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, @@ -1615,6 +1755,19 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage 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, @@ -1641,6 +1794,19 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage 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, + ); + } + if let Some(mismatch) = next_fact_after( facts, DeploymentFactKind::DetectionMismatch, @@ -1666,6 +1832,19 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage 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, @@ -1772,10 +1951,16 @@ fn insufficient( coverage: &[SccmDeploymentCoverage], ) -> Outcome { let group = phase.artifact_group(); + let physical_gap = coverage_for_group(coverage, group) + .is_none_or(|row| row.state != SccmCoverageState::Captured); Outcome { phase, state: SccmDeploymentState::InsufficientEvidence, - classification: SccmDeploymentClassification::InsufficientEvidence, + classification: if physical_gap { + SccmDeploymentClassification::InsufficientEvidence + } else { + SccmDeploymentClassification::Symptom + }, confidence: SccmDeploymentConfidence::Low, last_successful_phase, next_artifact: Some(SccmDeploymentArtifactRequest { @@ -1783,6 +1968,7 @@ fn insufficient( 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)), @@ -1790,6 +1976,28 @@ fn insufficient( } } +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 // --------------------------------------------------------------------------- @@ -1803,7 +2011,8 @@ fn insufficient( fn source_local_observations( evidence: &[SccmEvidence], artifacts_by_id: &BTreeMap<&str, &SccmArtifact>, - admitted_artifact_ids: &BTreeSet<&str>, + 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) { @@ -1821,7 +2030,8 @@ fn source_local_observations( .into_iter() .filter_map(|(artifact_id, evidence)| { let _artifact = artifacts_by_id.get(artifact_id)?; - if admitted_artifact_ids.contains(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 @@ -1845,9 +2055,13 @@ fn source_local_observations( key_confidence, confidence_ceiling: SccmDeploymentConfidence::Low, correlation_eligible: false, - reason: + 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(), + .to_owned() + }, evidence: SccmEvidenceRef { artifact_id: artifact_id.to_owned(), entry_id: format!("{artifact_id}:{start}-{end}"), @@ -1890,6 +2104,7 @@ 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 @@ -1950,6 +2165,10 @@ fn finding_text(finding_id: &str) -> (&'static str, &'static str) { "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.", @@ -2072,18 +2291,6 @@ fn build_findings( .collect::>(); coverage_gaps.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id)); coverage_gaps.dedup(); - if coverage_gaps.is_empty() { - // An insufficient-evidence finding always names what is - // missing. With no incomplete artifact to blame, the gap is the - // group itself: bytes exist, the needed record does not. - if let Some(group) = first.coverage_gap_group { - coverage_gaps.push(SccmFindingCoverageGap { - artifact_id: group.to_owned(), - role: SccmRole::Client, - coverage: SccmCoverageState::Absent, - }); - } - } let mut builder = SccmFindingBuilder::new(finding_id) .class(first.class.clone()) 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 index 97ab0f670..623821c8c 100644 --- 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 @@ -18,7 +18,7 @@ "phase": "locateContent", "state": "insufficientEvidence", "lastSuccessfulPhase": "requirements", - "classification": "insufficientEvidence", + "classification": "symptom", "confidence": "low", "confidenceCeiling": "low", "coverageGapArtifactIds": [], diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/expected.json index 674b263a1..bcd3b65db 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary/expected.json @@ -4,9 +4,9 @@ "scenario":"rotation-boundary", "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","client-content"]}, + "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-intent","state":"captured"},{"logicalArtifactId":"client-content","state":"capped","artifactIds":["deployment-rotation-boundary-content-current","deployment-rotation-boundary-content-lo"]}], + "coverage":[{"logicalArtifactId":"client-app-intent","state":"captured"},{"logicalArtifactId":"client-content","state":"captured","artifactIds":["deployment-rotation-boundary-content-current","deployment-rotation-boundary-content-lo"]}], "artifactProvenance":[ {"artifactId":"deployment-rotation-boundary-content-current","bytesCopied":207,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, {"artifactId":"deployment-rotation-boundary-content-lo","bytesCopied":147,"encoding":"utf-8","byteLimit":4096,"limitApplied":false}, @@ -16,7 +16,7 @@ "transactionId":"deployment:assignment:10000000-0000-0000-0000-000000000011", "key":{"keyProfileKind":"assignmentCi","assignmentId":"10000000-0000-0000-0000-000000000011","ciId":"20000000-0000-0000-0000-000000001011","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-rotation-boundary-content-current","deployment-rotation-boundary-content-lo"],"nextArtifact":{"logicalArtifactId":"client-content","reason":"capture a complete logical CCM content record without joining physical rotation fragments"}, + "phase":"locateContent","state":"insufficientEvidence","lastSuccessfulPhase":"requirements","classification":"symptom","confidence":"low","confidenceCeiling":"low","coverageGapArtifactIds":[],"nextArtifact":{"logicalArtifactId":"client-content","reason":"capture a complete logical CCM content record without joining physical rotation fragments"}, "evidence":[{"artifactId":"deployment-rotation-boundary-intent-current","startLine":1,"endLine":2}] }], "sourceLocalObservations":[], diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index 41bca4f73..059562f18 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -41,51 +41,51 @@ const SCENARIOS: [&str; 12] = [ const FULL_OUTPUT_SHA256: [(&str, &str); 12] = [ ( "bits-transfer-failure", - "e86ee7550f6a229086ef08f2aaa8c7c291cfa69872abc946d3e47851ed630ff4", + "a39eceeb0450a0e73d6cd5ac65e11d5e63ca2f135e3ea72b998ef52c24d9d7fb", ), ( "cache-failure", - "7c22725d771fcdd61104093a46aedd8c570a59ee016d9d982165b9a929decc96", + "d373bcf0f690f15f810f9a7fb1580b08afab9fcc2a8293aa4d25465c15b4c0b1", ), ( "dependency-failure", - "411da99d3414192f101873528af2c6fc1173fd0e5b9f2e923b1bc0c3f5121c97", + "731432421400d044a28b237f963f2b6845410333279aa0c915e779cd615c5dfa", ), ( "detection-false-negative", - "ef4b920914e7ea6735995c5ea63b8ff87b02903dc9858d74545c8b0a31edf778", + "b9201ff1529ffddd9c4fd506be38b854eeb169d8bd801000165a5ceca8dba0a3", ), ( "dp-content-missing", - "8869c0fe634ef1cbebd0581d4ee40453459ea464a25a7ea152c37d00f35dc2af", + "e377a8a760c6c6012f3507bf49ebcdd27613a655c217c697d0f862b6cac6cd22", ), ( "enforcement-exit", - "3f7c9ac906522dc9d681d28302135d080e8657024cd8eae7801174583cb77e3c", + "43eb2e58152fb36132d4ae47b5a1c5b1d231c6ee63e4c1f9737d44f50048e7fc", ), ( "incomplete", - "284852fe6062bfdff091d09156771a91d96dc32c67355d912fafc28cf16ec7f4", + "cee38f7767a1bf75b6abbb91ecb7c47805f5ae0f7936ef98e9cbce9267d4950c", ), ( "location-missing", - "b3bd1cbeae6f9b0e66912f59e27e07204a4905a61e47c7fc0c06abf5ed660c81", + "e31da1587a1e580cc666f7cfc3812b5c26d75c39b53415fc3648aed4444c52ec", ), ( "not-targeted", - "a859754fc0938b5b5f1a3bc1f24570c6ce373fb5ceeed631506efc9c58e871ab", + "45b6bbbcb4999608779a905e9fb0e7f7b6c9fa8027c5cdea3686ed0b20950c88", ), ( "requirements-failure", - "03919a838a19f744b3f0dea45d44bb9bd00b2acda9a5a8d83542515a41179577", + "bb56df98c42459b874ffde81cdf1dd2425bf4e6783a03daa28f921917fc82a90", ), ( "rotation-boundary", - "4f9dab01e081ffd7c73704948e0c2961c75035997ad1e0ac05202136d841b987", + "c9d74cbe7d336c92fa07d4a00a257725976341b4bc14c36742430de9ba3c1674", ), ( "success", - "71bf6d496fde8b7576d38718f33809a146e7ad0283562f729228f28fd3598c6f", + "b05f48b884152e71d602070a99cec1152c104799e0792d235a2f9a062fd4eba4", ), ]; @@ -1543,16 +1543,14 @@ const OBSERVED_KEY_KIND_SCENARIOS: [&str; 8] = [ "success", ]; -/// `rotation-boundary` additionally declares `client-content` as a validated -/// family even though none of its rotation fragments ever formed a record, so -/// its family list is not observation derived. -const OBSERVED_FAMILY_SCENARIOS: [&str; 7] = [ +const OBSERVED_FAMILY_SCENARIOS: [&str; 8] = [ "bits-transfer-failure", "cache-failure", "detection-false-negative", "dp-content-missing", "enforcement-exit", "incomplete", + "rotation-boundary", "success", ]; @@ -1720,9 +1718,123 @@ fn an_ambiguous_content_request_is_never_published_cross_side() { !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 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 = [ From 61a425611e7cf6ed71d3d7b39f35e2ee77621959 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 04:28:03 -0400 Subject: [PATCH 378/422] fix(sccm): recover ordered deployment outcomes --- .../src/sccm/client/deployment.rs | 51 +++-- .../tests/sccm_client_deployment.rs | 179 ++++++++++++++++++ 2 files changed, 212 insertions(+), 18 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs index 7f540369d..e21b4daaa 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs @@ -1436,17 +1436,6 @@ fn opposing_outcomes_are_ambiguous( }) } -fn facts_of_kind<'a>( - facts: &[&'a DeploymentFact], - kind: DeploymentFactKind, -) -> Vec<&'a DeploymentFact> { - facts - .iter() - .copied() - .filter(|fact| fact.kind == kind) - .collect() -} - /// The one cross-side output, and the only place a client key value leaves this /// reducer. It republishes the transaction key that already survived the /// ambiguity guard, and only when exactly one complete content record carries @@ -1560,7 +1549,12 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage ); } - if let Some(not_applicable) = first_fact(facts, DeploymentFactKind::IntentNotApplicable) { + 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, @@ -1595,8 +1589,16 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage ); } - let requirement_failures = facts_of_kind(facts, DeploymentFactKind::RequirementsFailed); - let dependency_failures = facts_of_kind(facts, DeploymentFactKind::DependencyFailed); + 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. @@ -1807,11 +1809,21 @@ fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage ); } - if let Some(mismatch) = next_fact_after( + let detection_mismatches = unrecovered_failures( facts, DeploymentFactKind::DetectionMismatch, - chain.last().copied(), - ) { + 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( @@ -1951,8 +1963,11 @@ fn insufficient( 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_none_or(|row| row.state != SccmCoverageState::Captured); + .is_some_and(|row| row.state != SccmCoverageState::Captured); Outcome { phase, state: SccmDeploymentState::InsufficientEvidence, diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index 059562f18..b8f34f421 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -1779,6 +1779,185 @@ fn equal_time_transfer_success_and_failure_are_not_a_confirmed_failure() { })); } +#[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( From 58ceeb96580f6aa51631496424263285a61944c4 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 01:34:46 -0400 Subject: [PATCH 379/422] fix(sccm): bind update facts to physical sources --- .../src/sccm/client/admission.rs | 10 +- .../src/sccm/client/updates.rs | 176 ++++++++++++++---- .../tests/sccm_client_updates.rs | 48 ++++- 3 files changed, 192 insertions(+), 42 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index fa4da67c2..16ea613eb 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -417,7 +417,7 @@ pub fn admit_client_evidence( let mut unavailable_source_basenames = canonical .capture_gaps .iter() - .filter(|gap| is_supported_raw_ccm_source(&gap.basename)) + .filter(|gap| is_supported_diagnostic_source(&gap.basename)) .map(|gap| gap.basename.clone()) .collect::>(); unavailable_source_basenames.extend( @@ -426,7 +426,9 @@ pub fn admit_client_evidence( .iter() .flat_map(|group| &group.fragments) .filter(|fragment| { - is_supported_raw_ccm_fragment(fragment) && !is_bound_complete_capture(fragment) + is_supported_diagnostic_source(&fragment.basename) + && (fragment.coverage != SccmCoverageState::Captured + || fragment.fragment_complete != Some(true)) }) .map(|fragment| fragment.basename.clone()), ); @@ -616,6 +618,10 @@ fn is_supported_raw_ccm_source(basename: &str) -> bool { 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) diff --git a/crates/cmtraceopen-parser/src/sccm/client/updates.rs b/crates/cmtraceopen-parser/src/sccm/client/updates.rs index 38ace0cce..3a10d0cc9 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/updates.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/updates.rs @@ -310,32 +310,7 @@ pub fn analyze_client_updates( .iter() .filter(|fact| fact.phase == phase) .collect::>(); - let latest_timestamp = phase_facts - .iter() - .filter_map(|fact| fact.timestamp.utc_millis) - .max(); - let latest = phase_facts - .into_iter() - .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_by_phase.insert(phase, effective); + effective_by_phase.insert(phase, effective_phase_fact(&phase_facts)); } let Some(last) = effective_by_phase.values().next_back() else { continue; @@ -563,6 +538,44 @@ pub fn analyze_client_updates( }) } +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> { @@ -598,9 +611,15 @@ fn update_coverage( } if declared { if let Some(state) = admitted.source_coverage(logical_artifact_id)? { - let all_complete = admitted - .require_captured_source(logical_artifact_id) - .is_ok(); + 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 { @@ -683,7 +702,17 @@ fn update_fact( let Some((phase, disposition)) = phase_disposition(evidence) else { return Ok(None); }; - let sealed_basename = admitted.source_basename_for_artifact(&evidence.reference.artifact_id)?; + 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(); @@ -704,14 +733,31 @@ fn update_fact( evidence: evidence.reference.clone(), timestamp: evidence.timestamp.clone(), location_services_source: location_services_group_admitted - && sealed_basename == Some("LocationServices.log") - && evidence - .component - .as_deref() - .is_some_and(|component| component.eq_ignore_ascii_case("LocationServices")), + && 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 @@ -1021,3 +1067,63 @@ fn request_for(phase: SccmClientUpdatePhase) -> SccmClientUpdateArtifactRequest ), } } + +#[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/tests/sccm_client_updates.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates.rs index 038e4660f..1d219c599 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates.rs @@ -71,11 +71,12 @@ fn admitted( for record in records { 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, record.time, record.component + record.message, time, record.component ) .into_bytes(); artifacts.push(SccmClientIntakeArtifact { @@ -700,7 +701,14 @@ fn counterpart_rejects_component_spoofing_and_privacy_bearing_handles() { .replace("safe:sup:lab", "safe:sup:prod.contoso.com"), }; - for record in [spoofed_source, privacy_client, privacy_sup] { + 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 @@ -709,6 +717,21 @@ fn counterpart_rejects_component_spoofing_and_privacy_bearing_handles() { } } +#[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"); @@ -770,6 +793,21 @@ fn equal_time_opposing_outcomes_are_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 = [ @@ -785,9 +823,9 @@ fn public_ids_include_the_full_stable_subject_discriminator() { id: "update-b", basename: "WUAHandler.log", group: "client-updates", - component: "ScanAgent", + component: "WUAHandler", time: "13:00:01.000", - message: keyed(UPDATE_ID, "323011", "Scan terminal failure"), + message: keyed(UPDATE_ID, "323011", "Evaluate terminal failure"), }, ]; From 408f98298c85869f2f99599d9773178c333290b4 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 01:39:57 -0400 Subject: [PATCH 380/422] fix(sccm): canonicalize client update rotations --- .../src/sccm/client/admission.rs | 20 +++-- .../tests/sccm_client_updates.rs | 76 ++++++++++++++++++- 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index 16ea613eb..439b7ec59 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -363,8 +363,10 @@ pub fn admit_client_evidence( .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(fragment.basename.clone()) + .entry(canonical_basename) .and_modify(|coverage| { if source_coverage_priority(&fragment.coverage) > source_coverage_priority(coverage) { @@ -374,8 +376,10 @@ pub fn admit_client_evidence( .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(capture_gap.basename.clone()) + .entry(canonical_basename) .and_modify(|coverage| { if source_coverage_priority(&capture_gap.coverage) > source_coverage_priority(coverage) @@ -418,7 +422,7 @@ pub fn admit_client_evidence( .capture_gaps .iter() .filter(|gap| is_supported_diagnostic_source(&gap.basename)) - .map(|gap| gap.basename.clone()) + .map(|gap| classify_artifact_name(&gap.basename, SccmRole::Client).basename) .collect::>(); unavailable_source_basenames.extend( canonical @@ -430,7 +434,7 @@ pub fn admit_client_evidence( && (fragment.coverage != SccmCoverageState::Captured || fragment.fragment_complete != Some(true)) }) - .map(|fragment| fragment.basename.clone()), + .map(|fragment| classify_artifact_name(&fragment.basename, SccmRole::Client).basename), ); let mut unbound_complete_captures = BTreeSet::new(); for fragment in &canonical.physical_artifacts { @@ -441,19 +445,19 @@ pub fn admit_client_evidence( if fragment.coverage != SccmCoverageState::Captured || fragment.fragment_complete != Some(true) { - unavailable_source_basenames.insert(fragment.basename.clone()); + unavailable_source_basenames.insert(classified.basename.clone()); 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(fragment.basename.clone()); + unavailable_source_basenames.insert(classified.basename.clone()); continue; } if !has_supported_payload_encoding(fragment) { - unavailable_source_basenames.insert(fragment.basename.clone()); + unavailable_source_basenames.insert(classified.basename.clone()); continue; } - source_basename_by_artifact.insert(fragment.artifact_id.clone(), fragment.basename.clone()); + 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 diff --git a/crates/cmtraceopen-parser/tests/sccm_client_updates.rs b/crates/cmtraceopen-parser/tests/sccm_client_updates.rs index 1d219c599..4a79d0f35 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_updates.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_updates.rs @@ -69,6 +69,16 @@ fn admitted( 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); @@ -88,14 +98,14 @@ fn admitted( 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, + rotation, coverage: SccmCoverageState::Captured, encoding: Some("utf-8".to_owned()), }, path_fingerprint: Some(path_fingerprint), rotation_lineage: None, relative_path: Some(format!( - "evidence/{}/current/{}", + "evidence/{}/{rotation_segment}/{}", record.group, record.basename )), fragment_complete: Some(true), @@ -607,7 +617,7 @@ fn canonical_capture_gap_keeps_the_transaction_incomplete() { record.message, record.time, record.component ) .into_bytes(); - let bundle = SccmClientIntakeBundle { + let mut bundle = SccmClientIntakeBundle { artifacts: vec![SccmClientIntakeArtifact { artifact: SccmArtifact { artifact_id: artifact_id.clone(), @@ -641,7 +651,10 @@ fn canonical_capture_gap_keeps_the_transaction_incomplete() { let admitted = admit_client_evidence( &bundle, &assessment, - &[SccmClientCapturedPayload::new(artifact_id, bytes).expect("bounded payload")], + &[ + SccmClientCapturedPayload::new(artifact_id.clone(), bytes.clone()) + .expect("bounded payload"), + ], ) .expect("sealed evidence with canonical gap"); @@ -655,6 +668,45 @@ fn canonical_capture_gap_keeps_the_transaction_incomplete() { 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] @@ -672,6 +724,22 @@ fn partial_rotation_is_reported_as_partial_coverage() { ); } +#[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 { From 2022b029850e7d4862a494b6066cbe26e5a0e9d1 Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:02:50 -0400 Subject: [PATCH 381/422] feat(sccm): bind DP sources to sealed server intake (#458) Reviewed bounded adapter slice only. The full package/content/version reducer, correlation, native collection, and live Windows validation remain open. --- .../sccm/server/windows/distribution_point.rs | 599 ++++++++++++++++ .../src/sccm/server/windows/mod.rs | 2 + .../tests/sccm_server_distribution_point.rs | 651 ++++++++++++++++++ 3 files changed, 1252 insertions(+) create mode 100644 crates/cmtraceopen-parser/src/sccm/server/windows/distribution_point.rs create mode 100644 crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs 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..7f97ae727 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/distribution_point.rs @@ -0,0 +1,599 @@ +//! Canonical-intake adapter for Distribution Point source evidence. +//! +//! This is deliberately an evidence and coverage reducer, not a content +//! transaction reducer. It consumes the already-normalized server intake +//! assessment and admits only the declared DP distribution CCM sources. Until +//! a versioned semantic fact profile is independently validated, it makes no +//! package outcome, client-impact, or cross-side causal claim. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; + +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"; +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, +} + +/// 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, + } +} + +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()); + + artifact.source_id == SCCM_DISTRIBUTION_POINT_SOURCE_ID + && 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: &crate::sccm::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) + && supported_source_version(artifact.source_version.as_deref()) + && 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 supported_source_version(value: Option<&str>) -> bool { + let Some(value) = value else { + return false; + }; + if value == "5.00.TEST" { + 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 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() { + 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: "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.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, + } +} diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs index 68e2c3952..45599ee0f 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs @@ -1,9 +1,11 @@ mod catalog; +mod distribution_point; mod intake; mod management_point; mod site_core; pub use catalog::*; +pub use distribution_point::*; pub use intake::*; pub use management_point::*; pub use site_core::*; 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..3bd61d887 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs @@ -0,0 +1,651 @@ +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::server::windows::{ + analyze_distribution_point, assess_server_intake, SccmServerArtifactPayload, + SccmServerIntakeAssessment, SccmServerIntakeError, +}; +use cmtraceopen_parser::sccm::{SccmCoverageState, SccmRole, SccmRotation, SccmTimeOrderingState}; +use serde_json::Value; + +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 { + let (mut manifest, payloads) = load_manifest_and_payloads("complete-multi-role"); + mutate(&mut manifest); + 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 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!(analysis.artifact_requests.len(), 1, "{context}"); + assert_eq!( + analysis.artifact_requests[0].logical_id, "distmgr", + "{context}" + ); + assert_eq!( + analysis.artifact_requests[0].role, + SccmRole::SiteServer, + "{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!(analysis.artifact_requests.len(), 2, "{context}"); + assert_eq!( + analysis.artifact_requests[0].logical_id, "distmgr", + "{context}" + ); + assert_eq!( + analysis.artifact_requests[0].role, + SccmRole::SiteServer, + "{context}" + ); + assert_eq!( + analysis.artifact_requests[1].logical_id, "smsDpProv", + "{context}" + ); + assert_eq!( + analysis.artifact_requests[1].role, + SccmRole::DistributionPoint, + "{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!(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 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!(analysis.artifact_requests.len(), 1); + assert_eq!(analysis.artifact_requests[0].logical_id, "distmgr"); + assert_eq!(analysis.artifact_requests[0].role, SccmRole::SiteServer); + 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!( + analysis + .artifact_requests + .iter() + .map(|request| (request.logical_id.as_str(), &request.role)) + .collect::>(), + vec![ + ("distmgr", &SccmRole::SiteServer), + ("smsDpProv", &SccmRole::DistributionPoint), + ] + ); + 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"); +} From acf645db1f95923ee39fda4f744014c3ec715feb Mon Sep 17 00:00:00 2001 From: Adam <27519+adamgell@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:20:03 -0400 Subject: [PATCH 382/422] feat(sccm): define optional WSUS supplemental intake contract (#459) * feat(sccm): catalogue optional WSUS supplemental intake * test(sccm): expose WSUS supplemental tuple gaps * fix(sccm): validate WSUS supplemental tuple * test(sccm): align WSUS coverage oracle with serialized output * fix(sccm): freeze synthetic WSUS rotation tuple --- .../src/sccm/server/windows/catalog.rs | 32 ++- .../src/sccm/server/windows/intake.rs | 65 ++++- .../supplemental-wsus-skipped/expected.json | 9 + .../supplemental-wsus-skipped/manifest.json | 38 +++ .../tests/sccm_server_intake.rs | 236 +++++++++++++++++- .../sccm_server_intake_fixture_contract.rs | 2 +- 6 files changed, 376 insertions(+), 6 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/supplemental-wsus-skipped/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/intake/supplemental-wsus-skipped/manifest.json diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs index 4ce68372c..d98b046d1 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs @@ -5,6 +5,7 @@ pub enum SccmServerSourceKind { CcmLog, IisW3c, StructuredSupplement, + ProfileDefined, } #[derive(Debug)] @@ -13,6 +14,9 @@ pub struct SccmServerSourceSpec { 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, } @@ -23,6 +27,7 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ producer_role: SccmRole::SiteServer, workflow_subject_role: None, logical_names: &["sitecomp", "hman"], + explicit_basename: None, source_kind: SccmServerSourceKind::CcmLog, supplemental: false, }, @@ -31,6 +36,7 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ producer_role: SccmRole::SiteServer, workflow_subject_role: None, logical_names: &["statmgr", "statesys"], + explicit_basename: None, source_kind: SccmServerSourceKind::CcmLog, supplemental: false, }, @@ -39,6 +45,7 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ producer_role: SccmRole::ManagementPoint, workflow_subject_role: None, logical_names: &["mpGetAuth", "mpCliReg", "mpRegistrationManager"], + explicit_basename: None, source_kind: SccmServerSourceKind::CcmLog, supplemental: false, }, @@ -47,6 +54,7 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ producer_role: SccmRole::ManagementPoint, workflow_subject_role: None, logical_names: &["mpGetPolicy", "mpLocation"], + explicit_basename: None, source_kind: SccmServerSourceKind::CcmLog, supplemental: false, }, @@ -55,6 +63,7 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ producer_role: SccmRole::SiteServer, workflow_subject_role: Some(SccmRole::ManagementPoint), logical_names: &["mpcontrol"], + explicit_basename: None, source_kind: SccmServerSourceKind::CcmLog, supplemental: false, }, @@ -63,6 +72,7 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ producer_role: SccmRole::ManagementPoint, workflow_subject_role: None, logical_names: &[], + explicit_basename: None, source_kind: SccmServerSourceKind::IisW3c, supplemental: true, }, @@ -71,6 +81,7 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ producer_role: SccmRole::SiteServer, workflow_subject_role: Some(SccmRole::DistributionPoint), logical_names: &["distmgr", "pkgXferMgr"], + explicit_basename: None, source_kind: SccmServerSourceKind::CcmLog, supplemental: false, }, @@ -79,6 +90,7 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ producer_role: SccmRole::DistributionPoint, workflow_subject_role: None, logical_names: &["smsDpProv", "pullDp"], + explicit_basename: None, source_kind: SccmServerSourceKind::CcmLog, supplemental: false, }, @@ -87,6 +99,7 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ producer_role: SccmRole::SiteServer, workflow_subject_role: Some(SccmRole::SoftwareUpdatePoint), logical_names: &["wcm", "wsyncmgr"], + explicit_basename: None, source_kind: SccmServerSourceKind::CcmLog, supplemental: false, }, @@ -95,9 +108,19 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ producer_role: SccmRole::SoftwareUpdatePoint, workflow_subject_role: None, 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, + }, ]; pub fn declared_server_source_catalog() -> &'static [SccmServerSourceSpec] { @@ -122,6 +145,12 @@ pub(crate) fn classify_declared_server_source( })?; if spec.source_kind != SccmServerSourceKind::CcmLog { + if spec + .explicit_basename + .is_some_and(|declared| declared != basename) + { + return None; + } return Some((spec, None)); } @@ -146,7 +175,7 @@ pub(crate) fn expected_family(source_id: &str) -> Option { SccmArtifactFamily::ManagementPoint } "server-dp-distribution" => SccmArtifactFamily::DistributionPoint, - "server-sup-sync" => SccmArtifactFamily::SoftwareUpdatePoint, + "server-sup-sync" | "server-sup-wsus" => SccmArtifactFamily::SoftwareUpdatePoint, _ => return None, }) } @@ -160,5 +189,6 @@ fn source_kind_matches(expected: SccmServerSourceKind, actual: &str) -> bool { SccmServerSourceKind::StructuredSupplement, "structuredSupplement" ) + | (SccmServerSourceKind::ProfileDefined, "profileDefined") ) } diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index d47ca5b92..2413951dc 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -16,7 +16,9 @@ use crate::sccm::{ SccmArtifactRequest, SccmCoverageState, SccmEvidence, SccmFinding, SccmRole, SccmRotation, }; -use super::catalog::{classify_declared_server_source, expected_family, SccmServerSourceKind}; +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. @@ -1074,6 +1076,13 @@ fn normalize_artifact( 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 { @@ -2295,6 +2304,49 @@ fn normalize_source_version( 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 + && (artifact.producer_host_handle.as_deref() != Some("synthetic:host:wsus-01") + || subject.instance_handle.as_deref() != Some("synthetic:subject:sup-01") + || source_version != Some("5.00.TEST") + || artifact.configured_path_provenance.path_fingerprint + != "synthetic:path:sup-wsus-health" + || artifact.rotation.kind != "providerDefined" + || artifact.rotation.value.is_some() + || artifact.rotation.lineage_id != "sup-wsus-health") + { + return Err(SccmServerIntakeError::InvalidArtifact); + } + + Ok(()) +} + fn validate_artifact_annotations( artifact: &RawServerArtifact, synthetic_fixture: bool, @@ -2406,6 +2458,7 @@ fn safe_manifest_artifact_id(value: &str, synthetic_fixture: bool) -> bool { | "sitecomp-current" | "sup-sync-capped" | "sup-sync-current" + | "sup-wsus-health-skipped" | "unknown-db-export" | "z-site-status" ); @@ -2423,6 +2476,7 @@ fn safe_source_id(value: &str, allow_unknown: bool, synthetic_fixture: bool) -> | "server-mp-iis" | "server-dp-distribution" | "server-sup-sync" + | "server-sup-wsus" | "unknown-db-supplement" ) || (allow_unknown && !synthetic_fixture @@ -2432,7 +2486,7 @@ fn safe_source_id(value: &str, allow_unknown: bool, synthetic_fixture: bool) -> fn safe_source_kind(value: &str, allow_unknown: bool, synthetic_fixture: bool) -> bool { matches!( value, - "ccmLog" | "iisW3c" | "structuredSupplement" | "unknown" + "ccmLog" | "iisW3c" | "structuredSupplement" | "profileDefined" | "unknown" ) || (allow_unknown && !synthetic_fixture && opaque_sha256_handle(value, "cmtraceopen.source-kind.sha256.v1:")) @@ -2466,6 +2520,7 @@ fn safe_lineage_id(value: &str, synthetic_fixture: bool) -> bool { | "sitecomp-lab" | "sup-sync-cap" | "sup-sync-lab" + | "sup-wsus-health" | "unknown-db-export" ); } @@ -2487,6 +2542,7 @@ fn safe_path_fingerprint(value: &str, synthetic_fixture: bool) -> bool { | "synthetic:path:site-default" | "synthetic:path:site-dp-control" | "synthetic:path:site-sup-control" + | "synthetic:path:sup-wsus-health" | "synthetic:path:unsupported-db" | "synthetic:path:z-site" ); @@ -2612,7 +2668,10 @@ fn safe_optional_handle(value: Option<&str>, synthetic_fixture: bool, domain: &s }; if synthetic_fixture { return match domain { - "host" => matches!(value, "synthetic:host:mp-01" | "synthetic:host:site-01"), + "host" => matches!( + value, + "synthetic:host:mp-01" | "synthetic:host:site-01" | "synthetic:host:wsus-01" + ), "subject" => { matches!( value, 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/sccm_server_intake.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs index ba12f3517..1d3fd55f7 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake.rs @@ -1,6 +1,7 @@ use cmtraceopen_parser::models::log_entry::Severity; use cmtraceopen_parser::sccm::server::windows::{ - assess_server_intake, SccmServerArtifactPayload, SccmServerIntakeError, + assess_server_intake, declared_server_source_catalog, SccmServerArtifactPayload, + SccmServerIntakeError, SccmServerSourceKind, }; use cmtraceopen_parser::sccm::{ SccmConfidence, SccmCoverageState, SccmFinding, SccmFindingBuilder, SccmFindingClass, @@ -1713,6 +1714,239 @@ fn server_intake_rejects_unversioned_future_unsupported_source_labels() { ); } +#[test] +fn server_intake_admits_only_the_catalogued_optional_wsus_supplement_contract() { + let (manifest_json, payloads) = load_bundle("supplemental-wsus-skipped"); + let assessment = assess_server_intake(&manifest_json, &payloads) + .expect("the catalogued optional WSUS supplement is assessable"); + let serialized = serde_json::to_value(&assessment).expect("assessment serializes"); + let artifact = artifact_json(&serialized, "sup-wsus-health-skipped"); + + let source = declared_server_source_catalog() + .iter() + .find(|source| source.source_id == "server-sup-wsus") + .expect("WSUS supplemental source is declared"); + assert_eq!(source.producer_role, SccmRole::WsUs); + assert_eq!( + source.workflow_subject_role, + Some(SccmRole::SoftwareUpdatePoint) + ); + assert_eq!(source.source_kind, SccmServerSourceKind::ProfileDefined); + assert!(source.supplemental); + + assert_eq!( + serialized["topology"]["rolesObserved"], + json!(["softwareUpdatePoint", "wsUs"]) + ); + assert_eq!(artifact["producerRole"], "wsUs"); + assert_eq!(artifact["workflowSubjectRole"], "softwareUpdatePoint"); + assert_eq!(artifact["sourceId"], "server-sup-wsus"); + assert_eq!(artifact["sourceKind"], "profileDefined"); + assert_eq!(artifact["sourceVersion"], "5.00.TEST"); + assert_eq!( + artifact["pathFingerprint"], + "synthetic:path:sup-wsus-health" + ); + assert_eq!(artifact["rotationLineageHandle"], "sup-wsus-health"); + assert_eq!(artifact["state"], "skipped"); + assert_eq!(artifact["family"], "softwareUpdatePoint"); + assert!(!artifact["parserEligible"] + .as_bool() + .expect("parser eligibility")); + assert!(serialized["findings"] + .as_array() + .expect("findings") + .is_empty()); + assert!(serialized["nextArtifactRequests"] + .as_array() + .expect("requests") + .is_empty()); + + for (field, replacement) in [ + ("artifactId", "free-form-wsus-artifact"), + ("sourceId", "free-form-wsus-source"), + ("sourceKind", "structuredSupplement"), + ("originalBasename", "WSUSHealth.json"), + ] { + let mut manifest = manifest_value(&manifest_json); + manifest["artifacts"][0][field] = Value::String(replacement.to_owned()); + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &payloads), + Err(SccmServerIntakeError::InvalidArtifact), + "{field} must remain in the frozen WSUS supplemental vocabulary" + ); + } + + let mut manifest = manifest_value(&manifest_json); + manifest["artifacts"][0]["sourceVersion"] = Value::String("5.00.TEST.0001".to_owned()); + assert_eq!( + assess_server_intake(&serialize_manifest(&manifest), &payloads), + Err(SccmServerIntakeError::InvalidArtifact), + "synthetic WSUS supplemental evidence accepts only version 5.00.TEST" + ); +} + +#[test] +fn wsus_supplement_expected_coverage_uses_only_serialized_fields() { + let expected = load_expected("supplemental-wsus-skipped"); + let (manifest_json, payloads) = load_bundle("supplemental-wsus-skipped"); + let assessment = assess_server_intake(&manifest_json, &payloads) + .expect("the catalogued optional WSUS supplement is assessable"); + let actual = serde_json::to_value(&assessment).expect("assessment serializes"); + 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()); + 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") + { + assert_eq!( + actual_row.get(key), + Some(expected_value), + "WSUS expected coverage must assert an actual serialized coverage field: {key}" + ); + } + } +} + +#[test] +fn server_intake_rejects_wsus_supplement_cross_tuple_mutations() { + type ManifestMutation = (&'static str, fn(&mut Value)); + + let (manifest_json, payloads) = load_bundle("supplemental-wsus-skipped"); + let mutations: [ManifestMutation; 8] = [ + ("subject role not observed", |manifest| { + manifest["topology"]["rolesObserved"] = json!(["wsUs"]); + }), + ("workflow subject omitted", |manifest| { + manifest["artifacts"][0] + .as_object_mut() + .expect("artifact is an object") + .remove("workflowSubject"); + }), + ("workflow subject role changed", |manifest| { + manifest["artifacts"][0]["workflowSubject"]["role"] = + Value::String("distributionPoint".to_owned()); + }), + ("producer host rebound", |manifest| { + manifest["artifacts"][0]["producerHostHandle"] = + Value::String("synthetic:host:mp-01".to_owned()); + }), + ("subject handle rebound", |manifest| { + manifest["artifacts"][0]["workflowSubject"]["instanceHandle"] = + Value::String("synthetic:subject:dp-01".to_owned()); + }), + ("source version omitted", |manifest| { + manifest["artifacts"][0] + .as_object_mut() + .expect("artifact is an object") + .remove("sourceVersion"); + }), + ("path fingerprint substituted", |manifest| { + manifest["artifacts"][0]["configuredPathProvenance"]["pathFingerprint"] = + Value::String("synthetic:path:site-sup-control".to_owned()); + }), + ("rotation lineage substituted", |manifest| { + manifest["artifacts"][0]["rotation"]["lineageId"] = + Value::String("sup-sync-lab".to_owned()); + }), + ]; + + let mut unexpected = Vec::new(); + for (name, mutate) in mutations { + let mut manifest = manifest_value(&manifest_json); + mutate(&mut manifest); + match assess_server_intake(&serialize_manifest(&manifest), &payloads) { + Err(SccmServerIntakeError::InvalidArtifact) => {} + 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"); diff --git a/crates/cmtraceopen-parser/tests/sccm_server_intake_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_intake_fixture_contract.rs index 62aba00bb..03d4c9c76 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_intake_fixture_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_intake_fixture_contract.rs @@ -37,7 +37,7 @@ fn server_intake_manifests() -> Vec<(String, Value)> { #[test] fn server_intake_uses_canonical_site_and_rotation_contracts() { let manifests = server_intake_manifests(); - assert_eq!(manifests.len(), 11, "server intake scenario matrix changed"); + assert_eq!(manifests.len(), 12, "server intake scenario matrix changed"); let mut failures = Vec::new(); for (scenario, manifest) in &manifests { From 936daac416c40d828f27706caf82a250a2fda452 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 01:31:56 -0400 Subject: [PATCH 383/422] feat(sccm): analyze software update point workflow --- .../src/sccm/server/windows/catalog.rs | 2 +- .../src/sccm/server/windows/intake.rs | 33 +- .../src/sccm/server/windows/mod.rs | 2 + .../server/windows/software_update_point.rs | 947 ++++++++++++++++++ .../sccm_server_software_update_point.rs | 318 ++++++ 5 files changed, 1298 insertions(+), 4 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/server/windows/software_update_point.rs create mode 100644 crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs index d98b046d1..a70bca7c6 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs @@ -106,7 +106,7 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ SccmServerSourceSpec { source_id: "server-sup-sync", producer_role: SccmRole::SoftwareUpdatePoint, - workflow_subject_role: None, + workflow_subject_role: Some(SccmRole::SoftwareUpdatePoint), logical_names: &["wsusCtrl", "supSetup"], explicit_basename: None, source_kind: SccmServerSourceKind::CcmLog, diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 2413951dc..91e446cbb 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -1239,7 +1239,7 @@ fn normalize_artifact( }) { return Err(SccmServerIntakeError::InvalidArtifact); } - if parse_errors > 0 { + if parse_errors > 0 && artifact.fragment_complete != Some(false) { state = SccmCoverageState::ParseFailed; } } else { @@ -2293,7 +2293,7 @@ fn normalize_source_version( return Ok(None); }; let safe = if synthetic_fixture { - value == "5.00.TEST" + matches!(value, "5.00.TEST" | "5.00.TEST.0001") } else { source_version_is_profile_eligible(value, false) || opaque_sha256_handle(value, "cmtraceopen.version.sha256.v1:") @@ -2408,6 +2408,7 @@ fn validate_artifact_annotations( match (artifact.truncated, artifact.fragment_complete) { (None, None) => {} + (Some(false), Some(false)) if artifact.capture_state == SccmCoverageState::Captured => {} (Some(true), Some(false)) if artifact.capture_state == SccmCoverageState::Capped => {} _ => return Err(SccmServerIntakeError::InvalidArtifact), } @@ -2415,7 +2416,7 @@ fn validate_artifact_annotations( } fn source_version_is_profile_eligible(value: &str, synthetic_fixture: bool) -> bool { - if synthetic_fixture && value == "5.00.TEST" { + if synthetic_fixture && matches!(value, "5.00.TEST" | "5.00.TEST.0001") { return true; } let mut parts = value.split('.'); @@ -2455,11 +2456,36 @@ fn safe_manifest_artifact_id(value: &str, synthetic_fixture: bool) -> bool { | "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" | "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" ); } @@ -2678,6 +2704,7 @@ fn safe_optional_handle(value: Option<&str>, synthetic_fixture: bool, domain: &s "synthetic:subject:dp-01" | "synthetic:subject:dp-02" | "synthetic:subject:sup-01" + | "safe:sup:lab-sup-01" ) } _ => false, diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs index 45599ee0f..cb299f3c2 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs @@ -3,9 +3,11 @@ mod distribution_point; mod intake; mod management_point; mod site_core; +mod software_update_point; pub use catalog::*; pub use distribution_point::*; pub use intake::*; pub use management_point::*; pub use site_core::*; +pub use software_update_point::*; 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..3d5d55d4f --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/software_update_point.rs @@ -0,0 +1,947 @@ +//! 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}; + +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, + 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 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: Option, +} + +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(); + 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) + { + if let Some(fact) = parse_fact(intake, artifact, evidence) { + facts_by_key.entry(fact.key.clone()).or_default().push(fact); + } + } + } + + reject_conflicting_transaction_keys(&mut facts_by_key); + let mut transactions = facts_by_key + .into_iter() + .filter_map(|(key, facts)| reduce_transaction(key, facts, &gap_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 start_line = evidence.reference.line_start?; + let end_line = evidence.reference.line_end?; + if start_line == 0 || end_line < start_line { + return None; + } + + Some(Fact { + key: FactKey { + sync_run_id, + site_code, + sup_handle, + update_id, + kb_id, + profile_id, + }, + phase, + disposition, + terminal, + evidence: SccmSoftwareUpdatePointEvidence { + artifact_id: evidence.reference.artifact_id.clone(), + start_line, + end_line, + }, + utc_millis: evidence.timestamp.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, + gap_artifacts: &[&SccmServerArtifactAssessment], +) -> Option { + facts.sort_by(|left, right| { + ( + left.utc_millis.unwrap_or(i64::MAX), + left.evidence.artifact_id.as_str(), + left.evidence.start_line, + left.evidence.end_line, + left.phase.rank(), + ) + .cmp(&( + right.utc_millis.unwrap_or(i64::MAX), + right.evidence.artifact_id.as_str(), + right.evidence.start_line, + right.evidence.end_line, + 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, + evidence: vec![fact.evidence.clone()], + } + }) + .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 optional_only_gap = !gap_artifacts.is_empty() + && gap_artifacts.iter().all(|artifact| { + artifact.source_id == SCCM_SOFTWARE_UPDATE_POINT_WSUS_SOURCE_ID + && matches!( + artifact.state, + SccmCoverageState::Skipped + | SccmCoverageState::Unsupported + | SccmCoverageState::Capped + ) + }); + + let (state, classification, confidence) = if terminal_success + && !terminal_failure + && (coverage_gap_artifact_ids.is_empty() || optional_only_gap) + { + ( + SccmSoftwareUpdatePointState::Succeeded, + SccmSoftwareUpdatePointClassification::Success, + if optional_only_gap { + SccmSoftwareUpdatePointConfidence::Medium + } else { + SccmSoftwareUpdatePointConfidence::High + }, + ) + } else if terminal_failure + && !terminal_success + && (coverage_gap_artifact_ids.is_empty() || optional_only_gap) + { + ( + SccmSoftwareUpdatePointState::Failed, + SccmSoftwareUpdatePointClassification::ConfirmedFailure, + if optional_only_gap { + 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() + .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 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) + || !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()); + 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), 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.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; + } + + for artifact in scoped_artifacts.iter().filter(|artifact| { + artifact.producer_role != SccmRole::Client + && artifact.state == SccmCoverageState::ParseFailed + }) { + 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 { + requests.insert(SccmSoftwareUpdatePointArtifactRequest { + source_id: artifact.source_id.clone(), + reason_code, + }); + } + if artifact.fragment_complete == Some(false) { + requests.insert(SccmSoftwareUpdatePointArtifactRequest { + source_id: artifact.source_id.clone(), + reason_code: SccmSoftwareUpdatePointRequestReason::CoverageRotationSplit, + }); + } + } + requests.into_iter().collect() +} 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..20266ad44 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs @@ -0,0 +1,318 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::server::windows::{ + analyze_software_update_point, assess_server_intake, SccmServerArtifactPayload, + SccmServerIntakeAssessment, +}; +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 scenario_root = corpus_root().join(scenario); + let manifest_json = + std::fs::read_to_string(scenario_root.join("manifest.json")).expect("manifest is readable"); + let mut 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::>(); + let expected_json = std::fs::read_to_string(scenario_root.join("expected.json")) + .expect("expected output is readable"); + let expected = serde_json::from_str(&expected_json).expect("expected output is valid JSON"); + canonicalize_preparation_manifest(&mut manifest); + let canonical_manifest = serde_json::to_string(&manifest).expect("manifest serializes"); + let intake = assess_server_intake(&canonical_manifest, &payloads).unwrap_or_else(|error| { + panic!("fixture is accepted by canonical server intake: {error:?}\n{canonical_manifest}") + }); + (intake, expected) +} + +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["workflowSubject"]["instanceHandle"] = + Value::String("synthetic:subject:sup-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"); + expected +} + +#[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 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!([])); +} From ca9121c3d291ee3442ce8d9ab60fda296daff677 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 01:51:36 -0400 Subject: [PATCH 384/422] fix(sccm): harden software update point analysis --- .../src/sccm/server/windows/intake.rs | 38 +++- .../server/windows/software_update_point.rs | 114 ++++++----- .../sccm_server_software_update_point.rs | 179 +++++++++++++++++- 3 files changed, 268 insertions(+), 63 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 91e446cbb..c7812a21b 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -1099,6 +1099,20 @@ fn normalize_artifact( declared_rotation, true, ) + } else if spec.source_kind == SccmServerSourceKind::ProfileDefined + && is_physical_state(&artifact.capture_state) + { + 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 { (family, None, None, false) } @@ -2331,17 +2345,25 @@ fn validate_declared_source_tuple( return Err(SccmServerIntakeError::InvalidArtifact); } - if synthetic_fixture - && (artifact.producer_host_handle.as_deref() != Some("synthetic:host:wsus-01") - || subject.instance_handle.as_deref() != Some("synthetic:subject:sup-01") + if synthetic_fixture { + let rotation_is_bounded = if is_physical_state(&artifact.capture_state) { + 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" - || artifact.rotation.kind != "providerDefined" - || artifact.rotation.value.is_some() - || artifact.rotation.lineage_id != "sup-wsus-health") - { - return Err(SccmServerIntakeError::InvalidArtifact); + || !rotation_is_bounded + || artifact.rotation.lineage_id != "sup-wsus-health" + { + return Err(SccmServerIntakeError::InvalidArtifact); + } } Ok(()) 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 index 3d5d55d4f..02b645afe 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/software_update_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/software_update_point.rs @@ -8,7 +8,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::Serialize; -use crate::sccm::{SccmCoverageState, SccmEvidence, SccmRole}; +use crate::sccm::{SccmCoverageState, SccmEvidence, SccmRole, SccmTimeOrderingState}; use super::{SccmServerArtifactAssessment, SccmServerIntakeAssessment}; @@ -231,6 +231,7 @@ pub struct SccmSoftwareUpdatePointSourceLocalObservation { #[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, } @@ -276,7 +277,7 @@ struct Fact { disposition: SccmSoftwareUpdatePointDisposition, terminal: bool, evidence: SccmSoftwareUpdatePointEvidence, - utc_millis: Option, + utc_millis: i64, } pub fn analyze_software_update_point( @@ -345,7 +346,7 @@ pub fn analyze_software_update_point( reject_conflicting_transaction_keys(&mut facts_by_key); let mut transactions = facts_by_key .into_iter() - .filter_map(|(key, facts)| reduce_transaction(key, facts, &gap_artifacts)) + .filter_map(|(key, facts)| reduce_transaction(key, facts, &scoped_artifacts)) .collect::>(); transactions.sort_by(|left, right| left.transaction_id.cmp(&right.transaction_id)); @@ -502,7 +503,11 @@ fn parse_fact( }; let start_line = evidence.reference.line_start?; let end_line = evidence.reference.line_end?; - if start_line == 0 || end_line < start_line { + let utc_millis = evidence.timestamp.utc_millis?; + if start_line == 0 + || end_line < start_line + || evidence.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc + { return None; } @@ -523,7 +528,7 @@ fn parse_fact( start_line, end_line, }, - utc_millis: evidence.timestamp.utc_millis, + utc_millis, }) } @@ -656,23 +661,10 @@ fn reject_conflicting_transaction_keys(facts_by_key: &mut BTreeMap, - gap_artifacts: &[&SccmServerArtifactAssessment], + scoped_artifacts: &[&SccmServerArtifactAssessment], ) -> Option { facts.sort_by(|left, right| { - ( - left.utc_millis.unwrap_or(i64::MAX), - left.evidence.artifact_id.as_str(), - left.evidence.start_line, - left.evidence.end_line, - left.phase.rank(), - ) - .cmp(&( - right.utc_millis.unwrap_or(i64::MAX), - right.evidence.artifact_id.as_str(), - right.evidence.start_line, - right.evidence.end_line, - right.phase.rank(), - )) + (left.utc_millis, left.phase.rank()).cmp(&(right.utc_millis, right.phase.rank())) }); if facts .first() @@ -713,44 +705,56 @@ fn reduce_transaction( }) .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 optional_only_gap = !gap_artifacts.is_empty() - && gap_artifacts.iter().all(|artifact| { - artifact.source_id == SCCM_SOFTWARE_UPDATE_POINT_WSUS_SOURCE_ID - && matches!( - artifact.state, - SccmCoverageState::Skipped - | SccmCoverageState::Unsupported - | SccmCoverageState::Capped - ) - }); + 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 terminal_success - && !terminal_failure - && (coverage_gap_artifact_ids.is_empty() || optional_only_gap) - { + 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_only_gap { + if optional_supplement_uninterpreted { SccmSoftwareUpdatePointConfidence::Medium } else { SccmSoftwareUpdatePointConfidence::High }, ) - } else if terminal_failure - && !terminal_success - && (coverage_gap_artifact_ids.is_empty() || optional_only_gap) - { + } else if terminal_failure && !terminal_success { ( SccmSoftwareUpdatePointState::Failed, SccmSoftwareUpdatePointClassification::ConfirmedFailure, - if optional_only_gap { + if optional_supplement_uninterpreted { SccmSoftwareUpdatePointConfidence::Medium } else { SccmSoftwareUpdatePointConfidence::High @@ -773,6 +777,7 @@ fn reduce_transaction( .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) @@ -815,11 +820,13 @@ fn reduce_transaction( 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, @@ -834,6 +841,7 @@ fn fact_chain_is_valid(facts: &[Fact]) -> bool { } } previous_phase = Some(fact.phase.rank()); + previous_utc = Some(fact.utc_millis); terminal_seen = fact.terminal; } true @@ -844,7 +852,7 @@ fn source_local_observations( ) -> Vec { let mut observations = Vec::new(); let mut ordinal = 1usize; - let mut split_groups = BTreeMap::<(String, String), Vec>::new(); + 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 @@ -852,6 +860,8 @@ fn source_local_observations( }) { 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(), )) @@ -870,10 +880,16 @@ fn source_local_observations( ordinal += 1; } - for artifact in scoped_artifacts.iter().filter(|artifact| { - artifact.producer_role != SccmRole::Client - && artifact.state == SccmCoverageState::ParseFailed - }) { + 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"), @@ -931,13 +947,21 @@ fn artifact_requests( | 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, }); diff --git a/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs index 20266ad44..84dbbf35e 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs @@ -52,10 +52,24 @@ fn load_canonical_intake_scenario(scenario: &str) -> SccmServerIntakeAssessment } 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 mut manifest: Value = serde_json::from_str(&manifest_json).expect("manifest is valid JSON"); + 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") @@ -75,15 +89,17 @@ fn load_scenario(scenario: &str) -> (SccmServerIntakeAssessment, Value) { }) }) .collect::>(); - let expected_json = std::fs::read_to_string(scenario_root.join("expected.json")) - .expect("expected output is readable"); - let expected = serde_json::from_str(&expected_json).expect("expected output is valid JSON"); - canonicalize_preparation_manifest(&mut manifest); + (manifest, payloads) +} + +fn assess_prepared_manifest( + manifest: Value, + payloads: &[SccmServerArtifactPayload], +) -> SccmServerIntakeAssessment { let canonical_manifest = serde_json::to_string(&manifest).expect("manifest serializes"); - let intake = assess_server_intake(&canonical_manifest, &payloads).unwrap_or_else(|error| { + assess_server_intake(&canonical_manifest, payloads).unwrap_or_else(|error| { panic!("fixture is accepted by canonical server intake: {error:?}\n{canonical_manifest}") - }); - (intake, expected) + }) } fn canonicalize_preparation_manifest(manifest: &mut Value) { @@ -194,8 +210,6 @@ fn canonicalize_preparation_manifest(manifest: &mut Value) { if source_id == "server-sup-wsus" { artifact["producerHostHandle"] = Value::String("synthetic:host:wsus-01".to_owned()); - artifact["workflowSubject"]["instanceHandle"] = - Value::String("synthetic:subject:sup-01".to_owned()); artifact["sourceVersion"] = Value::String("5.00.TEST".to_owned()); artifact["configuredPathProvenance"]["pathFingerprint"] = Value::String("synthetic:path:sup-wsus-health".to_owned()); @@ -251,6 +265,12 @@ fn production_projection(mut expected: Value) -> Value { .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 } @@ -316,3 +336,142 @@ fn an_unregistered_source_profile_cannot_emit_transactions() { 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()); +} From b1c0905c63dc1652aca0b340c75fa9357391eb89 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 02:06:29 -0400 Subject: [PATCH 385/422] fix(sccm): poison unusable SUP chronology --- .../server/windows/software_update_point.rs | 53 ++++++++++++------- .../sccm_server_software_update_point.rs | 32 +++++++++++ 2 files changed, 67 insertions(+), 18 deletions(-) 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 index 02b645afe..9c2ccf521 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/software_update_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/software_update_point.rs @@ -280,6 +280,11 @@ struct Fact { utc_millis: i64, } +enum ParsedFact { + Valid(Fact), + Poisoned(FactKey), +} + pub fn analyze_software_update_point( intake: &SccmServerIntakeAssessment, ) -> SccmSoftwareUpdatePointAnalysis { @@ -328,6 +333,7 @@ pub fn analyze_software_update_point( 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; @@ -337,12 +343,19 @@ pub fn analyze_software_update_point( .iter() .filter(|evidence| evidence.reference.artifact_id == artifact.artifact_id) { - if let Some(fact) = parse_fact(intake, artifact, evidence) { - facts_by_key.entry(fact.key.clone()).or_default().push(fact); + 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() @@ -467,7 +480,7 @@ fn parse_fact( intake: &SccmServerIntakeAssessment, artifact: &SccmServerArtifactAssessment, evidence: &SccmEvidence, -) -> Option { +) -> Option { let fields = parse_fixture_fields(&evidence.message)?; if fields.contains_key("ClientHandle") { return None; @@ -501,25 +514,29 @@ fn parse_fact( (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?; - let utc_millis = evidence.timestamp.utc_millis?; - if start_line == 0 - || end_line < start_line - || evidence.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc - { + if start_line == 0 || end_line < start_line { return None; } - Some(Fact { - key: FactKey { - sync_run_id, - site_code, - sup_handle, - update_id, - kb_id, - profile_id, - }, + Some(ParsedFact::Valid(Fact { + key, phase, disposition, terminal, @@ -529,7 +546,7 @@ fn parse_fact( end_line, }, utc_millis, - }) + })) } fn site_code_is_topology_compatible(site_code: &str, site_handle: &str) -> bool { diff --git a/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs index 84dbbf35e..c89d0b883 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs @@ -475,3 +475,35 @@ fn tied_or_unusable_timestamps_fail_closed_without_artifact_id_chronology() { 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" + ); +} From a2f6bb084dd26d003720d4c05167fa423023243a Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 04:00:41 -0400 Subject: [PATCH 386/422] feat(sccm): analyze distribution point content lifecycle --- crates/cmtraceopen-parser/src/sccm/catalog.rs | 6 + .../src/sccm/server/windows/catalog.rs | 11 +- .../sccm/server/windows/distribution_point.rs | 1016 +++- .../src/sccm/server/windows/intake.rs | 116 +- .../sccm/server/distribution_point/README.md | 15 +- .../absent-dp/expected.json | 62 +- .../site/current/PkgXferMgr.log | 1 + .../site/current/distmgr.log | 2 + .../backlog-blocked/expected.json | 124 + .../backlog-blocked/manifest.json | 49 + .../current/DataTransferService.log | 1 - .../client-only-looking-request/expected.json | 30 - .../client-only-looking-request/manifest.json | 45 - .../dp-02/current/SMSDPProv.log | 5 +- .../dp/current/SMSDPProv.log | 5 +- .../site-dp02/current/PkgXferMgr.log | 1 + .../site-dp02/current/distmgr.log | 2 + .../site/current/PkgXferMgr.log | 5 +- .../site/current/distmgr.log | 10 +- .../content-version-mismatch/expected.json | 465 +- .../content-version-mismatch/manifest.json | 52 +- .../dp/current/SMSDPProv.log | 2 + .../site/current/PkgXferMgr.log | 2 + .../site/current/distmgr.log | 2 + .../contradiction-recovery/expected.json | 193 + .../contradiction-recovery/manifest.json | 78 + .../site/current/distmgr.log | 4 +- .../distribution-failure/expected.json | 114 +- .../distribution-failure/manifest.json | 2 +- .../dp/current/SMSDPProv.log | 5 +- .../site/current/PkgXferMgr.log | 2 +- .../site/current/distmgr.log | 4 +- .../healthy-package/expected.json | 197 +- .../healthy-package/manifest.json | 6 +- .../site/current/distmgr.log | 4 +- .../incomplete/expected.json | 151 +- .../incomplete/manifest.json | 2 +- .../dp/current}/SMSDPProv.log | 0 .../malformed-current/expected.json | 30 + .../malformed-current/manifest.json | 29 + .../rotation-boundary/expected.json | 53 +- .../rotation-boundary/manifest.json | 26 +- .../dp/current/SMSDPProv.log | 4 +- .../site/current/PkgXferMgr.log | 2 +- .../site/current/distmgr.log | 4 +- .../server-dp-serve/dp/current/SMSdpmon.log | 2 +- .../serve-observed/expected.json | 210 +- .../serve-observed/manifest.json | 8 +- .../site/current/PkgXferMgr.log | 1 + .../site/current/distmgr.log | 2 + .../transfer-deferred/expected.json | 124 + .../transfer-deferred/manifest.json | 49 + .../site/current/PkgXferMgr.log | 1 + .../site/current/distmgr.log | 2 + .../transfer-failure/expected.json | 121 + .../transfer-failure/manifest.json | 49 + .../site/current/PkgXferMgr.log | 2 +- .../site/current/distmgr.log | 4 +- .../transfer-retry/expected.json | 141 +- .../transfer-retry/manifest.json | 4 +- .../dp/current/SMSDPProv.log | 2 +- .../site/current/PkgXferMgr.log | 2 +- .../site/current/distmgr.log | 4 +- .../validation-failure/expected.json | 166 +- .../validation-failure/manifest.json | 6 +- .../tests/sccm_server_distribution_point.rs | 894 +++- ...ver_distribution_point_fixture_contract.rs | 4296 ----------------- .../tests/sccm_spine_contract.rs | 13 + 68 files changed, 4215 insertions(+), 4827 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/evidence/server-dp-distribution/site/current/PkgXferMgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/evidence/server-dp-distribution/site/current/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/backlog-blocked/manifest.json delete mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/evidence/client-content-control/current/DataTransferService.log delete mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/expected.json delete mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site-dp02/current/PkgXferMgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/content-version-mismatch/evidence/server-dp-distribution/site-dp02/current/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/evidence/server-dp-distribution/dp/current/SMSDPProv.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/evidence/server-dp-distribution/site/current/PkgXferMgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/evidence/server-dp-distribution/site/current/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/contradiction-recovery/manifest.json rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/{rotation-boundary/evidence/server-dp-distribution/dp/malformed => malformed-current/evidence/server-dp-distribution/dp/current}/SMSDPProv.log (100%) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/malformed-current/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/malformed-current/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/evidence/server-dp-distribution/site/current/PkgXferMgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/evidence/server-dp-distribution/site/current/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-deferred/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/evidence/server-dp-distribution/site/current/PkgXferMgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/evidence/server-dp-distribution/site/current/distmgr.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/transfer-failure/manifest.json delete mode 100644 crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs index 21a4f990c..7e7a69d98 100644 --- a/crates/cmtraceopen-parser/src/sccm/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -622,6 +622,12 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ role: SccmRole::DistributionPoint, family: SccmArtifactFamily::DistributionPoint, }, + CatalogSpec { + basename: "SMSdpmon", + logical_name: "smsDpmon", + role: SccmRole::DistributionPoint, + family: SccmArtifactFamily::DistributionPoint, + }, CatalogSpec { basename: "PullDP", logical_name: "pullDp", diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs index a70bca7c6..1cbc4751e 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs @@ -94,6 +94,15 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ 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, @@ -174,7 +183,7 @@ pub(crate) fn expected_family(source_id: &str) -> Option { "server-mp-auth" | "server-mp-policy" | "server-mp-iis" => { SccmArtifactFamily::ManagementPoint } - "server-dp-distribution" => SccmArtifactFamily::DistributionPoint, + "server-dp-distribution" | "server-dp-serve" => SccmArtifactFamily::DistributionPoint, "server-sup-sync" | "server-sup-wsus" => SccmArtifactFamily::SoftwareUpdatePoint, _ => return None, }) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/distribution_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/distribution_point.rs index 7f97ae727..23b520274 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/distribution_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/distribution_point.rs @@ -1,15 +1,16 @@ //! Canonical-intake adapter for Distribution Point source evidence. //! -//! This is deliberately an evidence and coverage reducer, not a content -//! transaction reducer. It consumes the already-normalized server intake -//! assessment and admits only the declared DP distribution CCM sources. Until -//! a versioned semantic fact profile is independently validated, it makes no -//! package outcome, client-impact, or cross-side causal claim. +//! 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, @@ -24,6 +25,10 @@ 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."; @@ -80,6 +85,157 @@ pub struct SccmDistributionPointAnalysis { 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. @@ -170,6 +326,647 @@ pub fn analyze_distribution_point( } } +/// 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 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() { + if phase == SccmDistributionPointContentPhase::ServeOrReport { + outcome = Some(if envelope.facts.iter().any(|fact| fact.phase == phase) { + SccmDistributionPointContentState::Contradictory + } else { + SccmDistributionPointContentState::Succeeded + }); + } else { + let has_non_monotonic_or_downstream = + envelope.facts.iter().any(|fact| fact.phase >= phase); + outcome = Some(if has_non_monotonic_or_downstream { + 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 has_downstream = envelope.facts.iter().any(|fact| { + fact.phase > phase + && fact + .timestamp + .utc_millis + .is_some_and(|timestamp| timestamp > latest_timestamp) + }); + outcome = Some(if has_downstream { + 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(), @@ -202,8 +999,10 @@ fn is_dp_distribution_artifact(artifact: &SccmServerArtifactAssessment) -> bool }; let classified = classify_artifact_name(basename, artifact.producer_role.clone()); - artifact.source_id == SCCM_DISTRIBUTION_POINT_SOURCE_ID - && artifact.source_kind == "ccmLog" + 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| { @@ -220,7 +1019,7 @@ fn is_dp_distribution_artifact(artifact: &SccmServerArtifactAssessment) -> bool fn admitted_for_source_observation( artifact: &SccmServerArtifactAssessment, - evidence: &crate::sccm::SccmEvidence, + evidence: &SccmEvidence, ) -> bool { artifact.state == SccmCoverageState::Captured && artifact.profile_eligible @@ -239,7 +1038,6 @@ fn artifact_metadata_is_congruent( && artifact.profile_eligible && artifact.parser_eligible && artifact.fragment_complete != Some(false) - && supported_source_version(artifact.source_version.as_deref()) && rotation_is_canonical_for_artifact(artifact) && safe_assessed_handle(&intake.topology.capture_host_handle) && safe_assessed_handle(&intake.topology.site_handle) @@ -252,30 +1050,6 @@ fn artifact_metadata_is_congruent( && coverage_is_congruent(intake, artifact, artifact_id_counts) } -fn supported_source_version(value: Option<&str>) -> bool { - let Some(value) = value else { - return false; - }; - if value == "5.00.TEST" { - 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 rotation_is_canonical_for_artifact(artifact: &SccmServerArtifactAssessment) -> bool { let Some(basename) = artifact.original_basename.as_deref() else { return false; @@ -493,11 +1267,18 @@ fn artifact_requests(gaps: &[SccmDistributionPointCoverageGap]) -> Vec Vec &str { 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/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index c7812a21b..3989068c8 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -2307,7 +2307,7 @@ fn normalize_source_version( return Ok(None); }; let safe = if synthetic_fixture { - matches!(value, "5.00.TEST" | "5.00.TEST.0001") + 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:") @@ -2438,7 +2438,7 @@ fn validate_artifact_annotations( } fn source_version_is_profile_eligible(value: &str, synthetic_fixture: bool) -> bool { - if synthetic_fixture && matches!(value, "5.00.TEST" | "5.00.TEST.0001") { + if synthetic_fixture && matches!(value, "5.00.TEST" | "5.00.TEST.0001" | "5.00.TEST.0002") { return true; } let mut parts = value.split('.'); @@ -2468,6 +2468,43 @@ fn safe_manifest_artifact_id(value: &str, synthetic_fixture: bool) -> bool { | "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" @@ -2523,6 +2560,7 @@ fn safe_source_id(value: &str, allow_unknown: bool, synthetic_fixture: bool) -> | "server-mp-policy" | "server-mp-iis" | "server-dp-distribution" + | "server-dp-serve" | "server-sup-sync" | "server-sup-wsus" | "unknown-db-supplement" @@ -2554,6 +2592,42 @@ fn safe_lineage_id(value: &str, synthetic_fixture: bool) -> bool { 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" @@ -2581,6 +2655,44 @@ fn safe_path_fingerprint(value: &str, synthetic_fixture: bool) -> bool { 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" 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 index f6a9d16e3..3962c1bec 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/README.md +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/README.md @@ -2,16 +2,16 @@ This directory is test-only input for Issue `#329`. -Site-server logs are physical captures, not per-DP projections. The multi-DP -scenario therefore stores `distmgr.log` and `PkgXferMgr.log` once and binds -exact DP/content/version identity from each normalized logical CCM record. +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 a preparation label, not a frozen production API. -- Exact package/content/version/DP/profile keys keep versions and DPs +- `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 @@ -26,6 +26,9 @@ exact DP/content/version identity from each normalized logical CCM record. - 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, and rejects adversarial mutations. +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 index cebefbf55..70d5d6fca 100644 --- 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 @@ -1,20 +1,50 @@ { - "contractState": "proposedPendingReviewed318And335", - "workflow": "distributionPoint", - "scenario": "absent-dp", - "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], - "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, - "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, - "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, - "coverage": [ - {"artifactId": "dp-absent-01-distmgr", "state": "absent"}, - {"artifactId": "dp-absent-02-provider", "state": "absent"} - ], - "transactions": [], - "sourceLocalObservations": [], "artifactRequests": [ - {"sourceId": "server-dp-distribution", "reasonCode": "coverageAbsent"} + { + "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" + } ], - "clientCausalClaims": [], - "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} + "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/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/client-only-looking-request/evidence/client-content-control/current/DataTransferService.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/evidence/client-content-control/current/DataTransferService.log deleted file mode 100644 index aa97c1886..000000000 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/evidence/client-content-control/current/DataTransferService.log +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/expected.json deleted file mode 100644 index 4cacb4db2..000000000 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/expected.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "contractState": "proposedPendingReviewed318And335", - "workflow": "distributionPoint", - "scenario": "client-only-looking-request", - "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], - "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, - "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, - "roleAssessment": {"distributionPointObserved": false, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, - "coverage": [ - {"artifactId": "dp-client-control-01-data-transfer", "state": "captured"}, - {"artifactId": "dp-client-control-02-server-absent", "state": "absent"} - ], - "transactions": [], - "sourceLocalObservations": [ - { - "observationId": "client-control-01", - "classification": "ignoredClientEvidence", - "confidence": "low", - "confidenceCeiling": "low", - "correlationEligible": false, - "artifactIds": ["dp-client-control-01-data-transfer"], - "evidence": [{"artifactId": "dp-client-control-01-data-transfer", "startLine": 1, "endLine": 1}] - } - ], - "artifactRequests": [ - {"sourceId": "server-dp-distribution", "reasonCode": "coverageAbsent"} - ], - "clientCausalClaims": [], - "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} -} diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/manifest.json deleted file mode 100644 index fab490129..000000000 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/client-only-looking-request/manifest.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "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": ["siteServer"]}, - "artifacts": [ - { - "artifactId": "dp-client-control-01-data-transfer", - "sourceId": "client-content-control", - "producerRole": "client", - "producerHostHandle": "safe:client:lab-client-01", - "workflowSubjectRole": "distributionPoint", - "workflowSubjectHandle": "safe:dp:lab-dp-01", - "sourceKind": "ccmLog", - "originalBasename": "DataTransferService.log", - "sanitizedSourcePath": "SYNTHETIC://client-control/Logs/DataTransferService.log", - "pathFingerprint": "synthetic:client-only-data-transfer", - "rotation": {"kind": "current", "lineageId": "client-only-data-transfer", "fragmentComplete": true}, - "captureState": "captured", - "sourceVersion": "5.00.TEST.0001", - "collectedUtc": "2026-07-30T12:20:00Z", - "encoding": "utf-8", - "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 386, - "relativePath": "evidence/client-content-control/current/DataTransferService.log" - }, - { - "artifactId": "dp-client-control-02-server-absent", - "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:client-only-server-absent", - "rotation": {"kind": "current", "lineageId": "client-only-server-absent"}, - "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/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 index 2db3daa9d..7360a78c5 100644 --- 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 @@ -1,3 +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 index b926b006a..5a7f28687 100644 --- 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 @@ -1,3 +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 index 4557e6487..ad573c57e 100644 --- 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 @@ -1,3 +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 index 24a75c73e..578de33ea 100644 --- 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 @@ -1,6 +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 index 5d690be0b..cbd5af777 100644 --- 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 @@ -1,81 +1,420 @@ { - "contractState": "proposedPendingReviewed318And335", - "workflow": "distributionPoint", - "scenario": "content-version-mismatch", - "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], - "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, - "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, - "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, - "coverage": [ - {"artifactId": "dp-version-01-distmgr", "state": "captured"}, - {"artifactId": "dp-version-02-pkgxfer", "state": "captured"}, - {"artifactId": "dp-version-03-provider", "state": "captured"}, - {"artifactId": "dp-version-04-provider-dp02", "state": "captured"} + "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": [ { - "transactionId": "dp:LAB00005:content-epsilon:v1:safe:dp:lab-dp-01", - "key": {"packageId": "LAB00005", "contentId": "content-epsilon", "contentVersion": 1, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, - "topologyCompatibility": "exact", - "correlationEligible": true, - "state": "succeeded", "classification": "success", - "confidence": "high", - "confidenceCeiling": "high", - "lastSuccessfulPhase": "serveOrReport", - "nextSourceId": null, - "coverageGapArtifactIds": [], + "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": [ - {"observationId": "01-v1-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 1, "endLine": 1}]}, - {"observationId": "02-v1-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 2, "endLine": 2}]}, - {"observationId": "03-v1-transfer", "phase": "transfer", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-02-pkgxfer", "startLine": 1, "endLine": 1}]}, - {"observationId": "04-v1-validate", "phase": "validate", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-03-provider", "startLine": 1, "endLine": 1}]}, - {"observationId": "05-v1-available", "phase": "makeAvailable", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-03-provider", "startLine": 2, "endLine": 2}]}, - {"observationId": "06-v1-report", "phase": "serveOrReport", "disposition": "succeeded", "terminal": true, "evidence": [{"artifactId": "dp-version-03-provider", "startLine": 3, "endLine": 3}]} - ] + { + "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" }, { - "transactionId": "dp:LAB00005:content-epsilon:v1:safe:dp:lab-dp-02", - "key": {"packageId": "LAB00005", "contentId": "content-epsilon", "contentVersion": 1, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-02", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, - "topologyCompatibility": "exact", - "correlationEligible": true, - "state": "succeeded", "classification": "success", - "confidence": "high", - "confidenceCeiling": "high", - "lastSuccessfulPhase": "serveOrReport", - "nextSourceId": null, - "coverageGapArtifactIds": [], + "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": [ - {"observationId": "01-dp02-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 3, "endLine": 3}]}, - {"observationId": "02-dp02-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 4, "endLine": 4}]}, - {"observationId": "03-dp02-transfer", "phase": "transfer", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-02-pkgxfer", "startLine": 2, "endLine": 2}]}, - {"observationId": "04-dp02-validate", "phase": "validate", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-04-provider-dp02", "startLine": 1, "endLine": 1}]}, - {"observationId": "05-dp02-available", "phase": "makeAvailable", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-04-provider-dp02", "startLine": 2, "endLine": 2}]}, - {"observationId": "06-dp02-report", "phase": "serveOrReport", "disposition": "succeeded", "terminal": true, "evidence": [{"artifactId": "dp-version-04-provider-dp02", "startLine": 3, "endLine": 3}]} - ] + { + "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" }, { - "transactionId": "dp:LAB00005:content-epsilon:v2:safe:dp:lab-dp-01", - "key": {"packageId": "LAB00005", "contentId": "content-epsilon", "contentVersion": 2, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, - "topologyCompatibility": "exact", - "correlationEligible": true, - "state": "deferred", "classification": "blockedOrDeferred", "confidence": "medium", - "confidenceCeiling": "medium", - "lastSuccessfulPhase": "distribute", - "nextSourceId": null, - "coverageGapArtifactIds": [], + "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": [ - {"observationId": "01-v2-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 5, "endLine": 5}]}, - {"observationId": "02-v2-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-version-01-distmgr", "startLine": 6, "endLine": 6}]}, - {"observationId": "03-v2-transfer-retry", "phase": "transfer", "disposition": "retrying", "terminal": false, "evidence": [{"artifactId": "dp-version-02-pkgxfer", "startLine": 3, "endLine": 3}]} - ] + { + "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" } ], - "sourceLocalObservations": [], - "artifactRequests": [], - "clientCausalClaims": [], - "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} + "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 index 6b8af19f0..29132a75c 100644 --- 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 @@ -16,6 +16,7 @@ "producerRole": "siteServer", "producerHostHandle": "safe:server:lab-pri-01", "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", "workflowSubjectBasis": "manifestTopology", "sourceKind": "ccmLog", "originalBasename": "distmgr.log", @@ -27,7 +28,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 2106, + "bytesCopied": 1292, "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" }, { @@ -36,6 +37,7 @@ "producerRole": "siteServer", "producerHostHandle": "safe:server:lab-pri-01", "workflowSubjectRole": "distributionPoint", + "workflowSubjectHandle": "safe:dp:lab-dp-01", "workflowSubjectBasis": "manifestTopology", "sourceKind": "ccmLog", "originalBasename": "PkgXferMgr.log", @@ -47,7 +49,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 1058, + "bytesCopied": 649, "relativePath": "evidence/server-dp-distribution/site/current/PkgXferMgr.log" }, { @@ -67,7 +69,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 1062, + "bytesCopied": 651, "relativePath": "evidence/server-dp-distribution/dp/current/SMSDPProv.log" }, { @@ -87,8 +89,50 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 1062, + "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 index 0b7aa917f..eae2b0212 100644 --- 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 @@ -1,2 +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 index 6db59d4f3..3394ffeb1 100644 --- 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 @@ -1,35 +1,97 @@ { - "contractState": "proposedPendingReviewed318And335", - "workflow": "distributionPoint", - "scenario": "distribution-failure", - "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], - "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, - "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, - "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, - "coverage": [ - {"artifactId": "dp-distribution-failure-01-distmgr", "state": "captured"} - ], + "artifactRequests": [], + "coverageGaps": [], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, "transactions": [ { - "transactionId": "dp:LAB00002:content-beta:v1:safe:dp:lab-dp-01", - "key": {"packageId": "LAB00002", "contentId": "content-beta", "contentVersion": 1, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, - "topologyCompatibility": "exact", - "correlationEligible": true, - "state": "failed", "classification": "confirmedFailure", "confidence": "high", - "confidenceCeiling": "high", - "lastSuccessfulPhase": "receiveContent", - "nextSourceId": null, - "coverageGapArtifactIds": [], + "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": [ - {"observationId": "01-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-distribution-failure-01-distmgr", "startLine": 1, "endLine": 1}]}, - {"observationId": "02-distribute-failed", "phase": "distribute", "disposition": "failed", "terminal": true, "evidence": [{"artifactId": "dp-distribution-failure-01-distmgr", "startLine": 2, "endLine": 2}]} - ] + { + "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" } ], - "sourceLocalObservations": [], - "artifactRequests": [], - "clientCausalClaims": [], - "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} + "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 index 779a87c46..473a55ecd 100644 --- 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 @@ -22,7 +22,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 692, + "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 index 0474c8b44..7d0ca1e39 100644 --- 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 @@ -1,3 +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 index 85ad5dd18..9f0226aaa 100644 --- 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 @@ -1 +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 index 697091415..7ef064157 100644 --- 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 @@ -1,2 +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 index 5eacf62f1..88f63f1d4 100644 --- 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 @@ -1,61 +1,162 @@ { - "contractState": "proposedPendingReviewed318And335", - "workflow": "distributionPoint", - "scenario": "healthy-package", - "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], - "analysisContract": { - "independentReducer": true, - "consumesClientOutput": false, - "crossSideCorrelationPerformed": false - }, - "extractionProfile": { - "selectionState": "selectedSynthetic", - "profileId": "dp-server-5.00.test-v1", - "validatedRole": "distributionPoint" - }, - "roleAssessment": { - "distributionPointObserved": true, - "roleAbsentInferred": false, - "missingDefaultPathInterpretation": "sourceCoverageOnly" + "artifactRequests": [], + "coverageGaps": [], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 }, - "coverage": [ - {"artifactId": "dp-healthy-01-distmgr", "state": "captured"}, - {"artifactId": "dp-healthy-02-pkgxfer", "state": "captured"}, - {"artifactId": "dp-healthy-03-provider", "state": "captured"} - ], + "schemaVersion": 1, "transactions": [ { - "transactionId": "dp:LAB00001:content-alpha:v1:safe:dp:lab-dp-01", + "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": { - "packageId": "LAB00001", "contentId": "content-alpha", "contentVersion": 1, + "distributionPointHandle": "synthetic:subject:dp-01", + "extractionProfileId": "dp-server-5.00.test-v1", + "extractionProfileVersion": 1, + "packageId": "LAB00001", "siteCode": "LAB", - "distributionPointHandle": "safe:dp:lab-dp-01", - "confidence": "exact", - "extractionProfileId": "dp-server-5.00.test-v1" + "topologySiteHandle": "synthetic:site:lab" }, - "topologyCompatibility": "exact", - "correlationEligible": true, - "state": "succeeded", - "classification": "success", - "confidence": "high", - "confidenceCeiling": "high", - "lastSuccessfulPhase": "serveOrReport", - "nextSourceId": null, - "coverageGapArtifactIds": [], + "lastProvenPhase": "makeAvailable", + "nextArtifact": null, "observations": [ - {"observationId": "01-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-healthy-01-distmgr", "startLine": 1, "endLine": 1}]}, - {"observationId": "02-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-healthy-01-distmgr", "startLine": 2, "endLine": 2}]}, - {"observationId": "03-transfer", "phase": "transfer", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-healthy-02-pkgxfer", "startLine": 1, "endLine": 1}]}, - {"observationId": "04-validate", "phase": "validate", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-healthy-03-provider", "startLine": 1, "endLine": 1}]}, - {"observationId": "05-available", "phase": "makeAvailable", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-healthy-03-provider", "startLine": 2, "endLine": 2}]}, - {"observationId": "06-report", "phase": "serveOrReport", "disposition": "succeeded", "terminal": true, "evidence": [{"artifactId": "dp-healthy-03-provider", "startLine": 3, "endLine": 3}]} - ] + { + "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" } ], - "sourceLocalObservations": [], - "artifactRequests": [], - "clientCausalClaims": [], - "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} + "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 index 03efabfb5..c376523cf 100644 --- 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 @@ -31,7 +31,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 698, + "bytesCopied": 642, "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" }, { @@ -51,7 +51,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 351, + "bytesCopied": 323, "relativePath": "evidence/server-dp-distribution/site/current/PkgXferMgr.log" }, { @@ -71,7 +71,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 1056, + "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 index e333a9589..921c7c2cb 100644 --- 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 @@ -1,2 +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 index f0629e642..8cb3d4c58 100644 --- 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 @@ -1,40 +1,131 @@ { - "contractState": "proposedPendingReviewed318And335", - "workflow": "distributionPoint", - "scenario": "incomplete", - "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], - "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, - "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, - "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, - "coverage": [ - {"artifactId": "dp-incomplete-01-distmgr", "state": "captured"}, - {"artifactId": "dp-incomplete-02-pkgxfer-denied", "state": "accessDenied"}, - {"artifactId": "dp-incomplete-03-provider-absent", "state": "absent"} + "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": [ { - "transactionId": "dp:LAB00007:content-eta:v1:safe:dp:lab-dp-01", - "key": {"packageId": "LAB00007", "contentId": "content-eta", "contentVersion": 1, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, - "topologyCompatibility": "exact", - "correlationEligible": true, - "state": "incomplete", "classification": "insufficientEvidence", "confidence": "low", - "confidenceCeiling": "low", - "lastSuccessfulPhase": "distribute", - "nextSourceId": "server-dp-distribution", - "coverageGapArtifactIds": ["dp-incomplete-02-pkgxfer-denied", "dp-incomplete-03-provider-absent"], + "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": [ - {"observationId": "01-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-incomplete-01-distmgr", "startLine": 1, "endLine": 1}]}, - {"observationId": "02-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-incomplete-01-distmgr", "startLine": 2, "endLine": 2}]} - ] + { + "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" } ], - "sourceLocalObservations": [], - "artifactRequests": [ - {"sourceId": "server-dp-distribution", "reasonCode": "coverageAbsent"}, - {"sourceId": "server-dp-distribution", "reasonCode": "coverageAccessDenied"} - ], - "clientCausalClaims": [], - "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} + "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 index 47872848c..3912bb4a8 100644 --- 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 @@ -22,7 +22,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 694, + "bytesCopied": 638, "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" }, { diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/evidence/server-dp-distribution/dp/malformed/SMSDPProv.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/malformed-current/evidence/server-dp-distribution/dp/current/SMSDPProv.log similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/evidence/server-dp-distribution/dp/malformed/SMSDPProv.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/malformed-current/evidence/server-dp-distribution/dp/current/SMSDPProv.log 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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/distribution_point/rotation-boundary/expected.json index 0c081832e..018925d55 100644 --- 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 @@ -1,25 +1,36 @@ { - "contractState": "proposedPendingReviewed318And335", - "workflow": "distributionPoint", - "scenario": "rotation-boundary", - "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], - "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, - "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, - "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, - "coverage": [ - {"artifactId": "dp-rotation-01-current-fragment", "state": "captured"}, - {"artifactId": "dp-rotation-02-lo-fragment", "state": "captured"}, - {"artifactId": "dp-rotation-03-malformed", "state": "parseFailed"} - ], - "transactions": [], - "sourceLocalObservations": [ - {"observationId": "rotation-01-split", "classification": "rotationSplit", "confidence": "low", "confidenceCeiling": "low", "correlationEligible": false, "artifactIds": ["dp-rotation-01-current-fragment", "dp-rotation-02-lo-fragment"], "evidence": [{"artifactId": "dp-rotation-01-current-fragment", "startLine": 1, "endLine": 1}, {"artifactId": "dp-rotation-02-lo-fragment", "startLine": 1, "endLine": 1}]}, - {"observationId": "rotation-02-malformed", "classification": "malformedEvidence", "confidence": "low", "confidenceCeiling": "low", "correlationEligible": false, "artifactIds": ["dp-rotation-03-malformed"], "evidence": [{"artifactId": "dp-rotation-03-malformed", "startLine": 1, "endLine": 1}]} - ], "artifactRequests": [ - {"sourceId": "server-dp-distribution", "reasonCode": "coverageMalformed"}, - {"sourceId": "server-dp-distribution", "reasonCode": "coverageRotationSplit"} + { + "logicalId": "distmgr", + "reason": "Collect the complete distmgr.log file.", + "role": "siteServer" + }, + { + "logicalId": "pkgXferMgr", + "reason": "Collect the complete PkgXferMgr.log file.", + "role": "siteServer" + } ], - "clientCausalClaims": [], - "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} + "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 index a4ad922e6..fbd6d0d74 100644 --- 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 @@ -15,7 +15,7 @@ "sourceKind": "ccmLog", "originalBasename": "distmgr.log", "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.log", - "pathFingerprint": "synthetic:rotation-current", + "pathFingerprint": "synthetic:rotation-distmgr", "rotation": {"kind": "current", "lineageId": "rotation-distmgr", "fragmentComplete": false}, "captureState": "captured", "sourceVersion": "5.00.TEST.0001", @@ -33,9 +33,9 @@ "workflowSubjectRole": "distributionPoint", "workflowSubjectHandle": "safe:dp:lab-dp-01", "sourceKind": "ccmLog", - "originalBasename": "distmgr.log", + "originalBasename": "distmgr.lo_", "sanitizedSourcePath": "SYNTHETIC://site-root/Logs/distmgr.lo_", - "pathFingerprint": "synthetic:rotation-lo", + "pathFingerprint": "synthetic:rotation-distmgr", "rotation": {"kind": "lo_", "lineageId": "rotation-distmgr", "fragmentComplete": false}, "captureState": "captured", "sourceVersion": "5.00.TEST.0001", @@ -44,26 +44,6 @@ "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, "bytesCopied": 84, "relativePath": "evidence/server-dp-distribution/site/lo_/distmgr.log" - }, - { - "artifactId": "dp-rotation-03-malformed", - "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:rotation-malformed", - "rotation": {"kind": "current", "lineageId": "rotation-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/malformed/SMSDPProv.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 index 5ff2d1528..dcf7827fe 100644 --- 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 @@ -1,2 +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 index e7df5a40b..6f3391cdf 100644 --- 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 @@ -1 +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 index 26acda749..f77c244ad 100644 --- 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 @@ -1,2 +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 index 8a93861fc..58fe78d34 100644 --- 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 @@ -1 +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 index 80e87d824..701ffb209 100644 --- 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 @@ -1,42 +1,186 @@ { - "contractState": "proposedPendingReviewed318And335", - "workflow": "distributionPoint", - "scenario": "serve-observed", - "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], - "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, - "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, - "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, - "coverage": [ - {"artifactId": "dp-serve-01-distmgr", "state": "captured"}, - {"artifactId": "dp-serve-02-pkgxfer", "state": "captured"}, - {"artifactId": "dp-serve-03-provider", "state": "captured"}, - {"artifactId": "dp-serve-04-status", "state": "captured"} - ], + "artifactRequests": [], + "coverageGaps": [], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, "transactions": [ { - "transactionId": "dp:LAB00006:content-zeta:v1:safe:dp:lab-dp-01", - "key": {"packageId": "LAB00006", "contentId": "content-zeta", "contentVersion": 1, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, - "topologyCompatibility": "exact", - "correlationEligible": true, - "state": "succeeded", "classification": "success", "confidence": "high", - "confidenceCeiling": "high", - "lastSuccessfulPhase": "serveOrReport", - "nextSourceId": null, - "coverageGapArtifactIds": [], + "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": [ - {"observationId": "01-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-serve-01-distmgr", "startLine": 1, "endLine": 1}]}, - {"observationId": "02-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-serve-01-distmgr", "startLine": 2, "endLine": 2}]}, - {"observationId": "03-transfer", "phase": "transfer", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-serve-02-pkgxfer", "startLine": 1, "endLine": 1}]}, - {"observationId": "04-validate", "phase": "validate", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-serve-03-provider", "startLine": 1, "endLine": 1}]}, - {"observationId": "05-available", "phase": "makeAvailable", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-serve-03-provider", "startLine": 2, "endLine": 2}]}, - {"observationId": "06-serve-observed", "phase": "serveOrReport", "disposition": "succeeded", "terminal": true, "evidence": [{"artifactId": "dp-serve-04-status", "startLine": 1, "endLine": 1}]} - ] + { + "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" } ], - "sourceLocalObservations": [], - "artifactRequests": [], - "clientCausalClaims": [], - "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} + "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 index 3edd339dd..6abd64e68 100644 --- 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 @@ -22,7 +22,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 696, + "bytesCopied": 640, "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" }, { @@ -42,7 +42,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 350, + "bytesCopied": 322, "relativePath": "evidence/server-dp-distribution/site/current/PkgXferMgr.log" }, { @@ -62,7 +62,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 701, + "bytesCopied": 645, "relativePath": "evidence/server-dp-distribution/dp/current/SMSDPProv.log" }, { @@ -82,7 +82,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 350, + "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 index 154901bed..8b99c906d 100644 --- 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 @@ -1 +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 index b0d629f5c..2d3ee92ad 100644 --- 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 @@ -1,2 +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 index 428e529ba..a1bb6dc7d 100644 --- 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 @@ -1,37 +1,124 @@ { - "contractState": "proposedPendingReviewed318And335", - "workflow": "distributionPoint", - "scenario": "transfer-retry", - "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], - "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, - "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, - "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, - "coverage": [ - {"artifactId": "dp-transfer-retry-01-distmgr", "state": "captured"}, - {"artifactId": "dp-transfer-retry-02-pkgxfer", "state": "captured"} + "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": [ { - "transactionId": "dp:LAB00003:content-gamma:v1:safe:dp:lab-dp-01", - "key": {"packageId": "LAB00003", "contentId": "content-gamma", "contentVersion": 1, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, - "topologyCompatibility": "exact", - "correlationEligible": true, - "state": "deferred", "classification": "blockedOrDeferred", "confidence": "medium", - "confidenceCeiling": "medium", - "lastSuccessfulPhase": "distribute", - "nextSourceId": null, - "coverageGapArtifactIds": [], + "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": [ - {"observationId": "01-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-transfer-retry-01-distmgr", "startLine": 1, "endLine": 1}]}, - {"observationId": "02-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-transfer-retry-01-distmgr", "startLine": 2, "endLine": 2}]}, - {"observationId": "03-transfer-retry", "phase": "transfer", "disposition": "retrying", "terminal": false, "evidence": [{"artifactId": "dp-transfer-retry-02-pkgxfer", "startLine": 1, "endLine": 1}]} - ] + { + "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" } ], - "sourceLocalObservations": [], - "artifactRequests": [], - "clientCausalClaims": [], - "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} + "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 index 923f68992..0ea3ae7e8 100644 --- 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 @@ -22,7 +22,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 698, + "bytesCopied": 642, "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" }, { @@ -42,7 +42,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 350, + "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 index 65223a49b..7e864a270 100644 --- 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 @@ -1 +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 index 4b42203ad..bb9eaea71 100644 --- 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 @@ -1 +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 index c80962a06..da9447a81 100644 --- 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 @@ -1,2 +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 index dc8309516..642e270ec 100644 --- 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 @@ -1,39 +1,145 @@ { - "contractState": "proposedPendingReviewed318And335", - "workflow": "distributionPoint", - "scenario": "validation-failure", - "stateChain": ["receiveContent", "distribute", "transfer", "validate", "makeAvailable", "serveOrReport"], - "analysisContract": {"independentReducer": true, "consumesClientOutput": false, "crossSideCorrelationPerformed": false}, - "extractionProfile": {"selectionState": "selectedSynthetic", "profileId": "dp-server-5.00.test-v1", "validatedRole": "distributionPoint"}, - "roleAssessment": {"distributionPointObserved": true, "roleAbsentInferred": false, "missingDefaultPathInterpretation": "sourceCoverageOnly"}, - "coverage": [ - {"artifactId": "dp-validation-failure-01-distmgr", "state": "captured"}, - {"artifactId": "dp-validation-failure-02-pkgxfer", "state": "captured"}, - {"artifactId": "dp-validation-failure-03-provider", "state": "captured"} - ], + "artifactRequests": [], + "coverageGaps": [], + "crossSideCorrelationPerformed": false, + "profile": { + "id": "dp-server-5.00.test-v1", + "stability": "experimental", + "version": 1 + }, + "schemaVersion": 1, "transactions": [ { - "transactionId": "dp:LAB00004:content-delta:v1:safe:dp:lab-dp-01", - "key": {"packageId": "LAB00004", "contentId": "content-delta", "contentVersion": 1, "siteCode": "LAB", "distributionPointHandle": "safe:dp:lab-dp-01", "confidence": "exact", "extractionProfileId": "dp-server-5.00.test-v1"}, - "topologyCompatibility": "exact", - "correlationEligible": true, - "state": "failed", "classification": "confirmedFailure", "confidence": "high", - "confidenceCeiling": "high", - "lastSuccessfulPhase": "transfer", - "nextSourceId": null, - "coverageGapArtifactIds": [], + "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": [ - {"observationId": "01-receive", "phase": "receiveContent", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-validation-failure-01-distmgr", "startLine": 1, "endLine": 1}]}, - {"observationId": "02-distribute", "phase": "distribute", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-validation-failure-01-distmgr", "startLine": 2, "endLine": 2}]}, - {"observationId": "03-transfer", "phase": "transfer", "disposition": "succeeded", "terminal": false, "evidence": [{"artifactId": "dp-validation-failure-02-pkgxfer", "startLine": 1, "endLine": 1}]}, - {"observationId": "04-validate-failed", "phase": "validate", "disposition": "failed", "terminal": true, "evidence": [{"artifactId": "dp-validation-failure-03-provider", "startLine": 1, "endLine": 1}]} - ] + { + "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" } ], - "sourceLocalObservations": [], - "artifactRequests": [], - "clientCausalClaims": [], - "correlationHandoff": {"issue": "#333", "performed": false, "timeOnlyEligible": false} + "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 index 4be8dc67c..29f9e3525 100644 --- 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 @@ -22,7 +22,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 698, + "bytesCopied": 642, "relativePath": "evidence/server-dp-distribution/site/current/distmgr.log" }, { @@ -42,7 +42,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 351, + "bytesCopied": 323, "relativePath": "evidence/server-dp-distribution/site/current/PkgXferMgr.log" }, { @@ -62,7 +62,7 @@ "collectedUtc": "2026-07-30T12:20:00Z", "encoding": "utf-8", "collectionLimit": {"byteLimit": 4096, "limitApplied": false}, - "bytesCopied": 345, + "bytesCopied": 317, "relativePath": "evidence/server-dp-distribution/dp/current/SMSDPProv.log" } ] diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs index 3bd61d887..96dfc05c5 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs @@ -1,11 +1,364 @@ use std::path::{Path, PathBuf}; use cmtraceopen_parser::sccm::server::windows::{ - analyze_distribution_point, assess_server_intake, SccmServerArtifactPayload, - SccmServerIntakeAssessment, SccmServerIntakeError, + analyze_distribution_point, analyze_distribution_point_content_from_server_intake, + assess_server_intake, SccmDistributionPointContentConfidence, + SccmDistributionPointContentPhase, SccmDistributionPointContentState, + 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::{SccmCoverageState, SccmRole, SccmRotation, SccmTimeOrderingState}; -use serde_json::Value; +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() +} + +#[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 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") @@ -46,9 +399,16 @@ fn assess_manifest( 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"); - mutate(&mut manifest); + let mut payloads = payloads; + mutate(&mut manifest, &mut payloads); assess_manifest(&manifest, &payloads) } @@ -57,6 +417,229 @@ fn load_assessment(scenario: &str) -> SccmServerIntakeAssessment { 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() @@ -133,14 +716,9 @@ fn assert_dp_sealed_guard_rejection( "Captured Distribution Point evidence is incomplete or outside the supported intake profile.", "{context}" ); - assert_eq!(analysis.artifact_requests.len(), 1, "{context}"); assert_eq!( - analysis.artifact_requests[0].logical_id, "distmgr", - "{context}" - ); - assert_eq!( - analysis.artifact_requests[0].role, - SccmRole::SiteServer, + artifact_request_contracts(&analysis.artifact_requests), + expected_site_server_artifact_requests(), "{context}" ); assert!(!analysis.cross_side_correlation_performed, "{context}"); @@ -171,23 +749,9 @@ fn assert_dp_intake_authority_invalid( gap.reason, "Canonical server intake authority could not be verified.", "{context}" ); - assert_eq!(analysis.artifact_requests.len(), 2, "{context}"); assert_eq!( - analysis.artifact_requests[0].logical_id, "distmgr", - "{context}" - ); - assert_eq!( - analysis.artifact_requests[0].role, - SccmRole::SiteServer, - "{context}" - ); - assert_eq!( - analysis.artifact_requests[1].logical_id, "smsDpProv", - "{context}" - ); - assert_eq!( - analysis.artifact_requests[1].role, - SccmRole::DistributionPoint, + artifact_request_contracts(&analysis.artifact_requests), + expected_all_dp_profile_artifact_requests(), "{context}" ); assert!(!analysis.cross_side_correlation_performed, "{context}"); @@ -201,6 +765,19 @@ fn distribution_point_adapter_projects_only_canonical_intake_observations_determ 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); @@ -240,6 +817,249 @@ fn distribution_point_adapter_projects_only_canonical_intake_observations_determ ); } +#[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| { @@ -474,9 +1294,10 @@ fn absent_dp_candidate_is_coverage_not_a_role_diagnosis() { analysis.coverage_gaps[0].reason, "Distribution Point source coverage is absent; recollect the declared source without changing its state." ); - assert_eq!(analysis.artifact_requests.len(), 1); - assert_eq!(analysis.artifact_requests[0].logical_id, "distmgr"); - assert_eq!(analysis.artifact_requests[0].role, SccmRole::SiteServer); + assert_eq!( + artifact_request_contracts(&analysis.artifact_requests), + expected_site_server_artifact_requests() + ); serde_json::to_value(&analysis).expect("coverage-only analysis serializes"); } @@ -490,15 +1311,8 @@ fn no_declared_dp_source_requests_both_bounded_sides_without_correlation() { assert_eq!(analysis.coverage_gaps.len(), 1); assert_eq!(analysis.coverage_gaps[0].producer_role, None); assert_eq!( - analysis - .artifact_requests - .iter() - .map(|request| (request.logical_id.as_str(), &request.role)) - .collect::>(), - vec![ - ("distmgr", &SccmRole::SiteServer), - ("smsDpProv", &SccmRole::DistributionPoint), - ] + artifact_request_contracts(&analysis.artifact_requests), + expected_all_dp_profile_artifact_requests() ); assert!(!analysis.cross_side_correlation_performed); } diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs deleted file mode 100644 index ae1f19957..000000000 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point_fixture_contract.rs +++ /dev/null @@ -1,4296 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; - -use chrono::DateTime; -use cmtraceopen_parser::sccm::{ - normalize_ccm_artifact, SccmArtifact, SccmCoverageState, SccmEvidence, SccmRole, SccmRotation, - SccmTimeOrderingState, -}; -use serde_json::{json, Value}; - -const SCENARIOS: &[&str] = &[ - "absent-dp", - "client-only-looking-request", - "content-version-mismatch", - "distribution-failure", - "healthy-package", - "incomplete", - "rotation-boundary", - "serve-observed", - "transfer-retry", - "validation-failure", -]; - -const STATE_CHAIN: &[&str] = &[ - "receiveContent", - "distribute", - "transfer", - "validate", - "makeAvailable", - "serveOrReport", -]; - -const EXACT_PROFILE: &str = "dp-server-5.00.test-v1"; -const EXACT_SOURCE_VERSION: &str = "5.00.TEST.0001"; -const EXACT_SITE: &str = "LAB"; -const EXACT_DP: &str = "safe:dp:lab-dp-01"; -const EXACT_DP_02: &str = "safe:dp:lab-dp-02"; -const EXACT_SITE_SERVER: &str = "safe:server:lab-pri-01"; -const EXACT_CLIENT: &str = "safe:client:lab-client-01"; -const APPROVED_PATH_FINGERPRINTS: &[&str] = &[ - "synthetic:absent-distmgr", - "synthetic:absent-provider", - "synthetic:client-only-data-transfer", - "synthetic:client-only-server-absent", - "synthetic:distribution-failure", - "synthetic:healthy-distmgr", - "synthetic:healthy-pkgxfer", - "synthetic:healthy-provider", - "synthetic:incomplete-distmgr", - "synthetic:incomplete-pkgxfer", - "synthetic:incomplete-provider", - "synthetic:retry-distmgr", - "synthetic:retry-pkgxfer", - "synthetic:rotation-current", - "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", -]; -const APPROVED_ROTATION_LINEAGES: &[&str] = &[ - "absent-distmgr", - "absent-provider", - "client-only-data-transfer", - "client-only-server-absent", - "distribution-failure", - "healthy-distmgr", - "healthy-pkgxfer", - "healthy-provider", - "incomplete-distmgr", - "incomplete-pkgxfer", - "incomplete-provider", - "retry-distmgr", - "retry-pkgxfer", - "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", -]; - -fn corpus_root() -> std::path::PathBuf { - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/sccm/server/distribution_point") -} - -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_nonempty_string<'a>( - value: &'a Value, - field: &str, - context: &str, -) -> Result<&'a str, String> { - required_string(value, field, context).and_then(|candidate| { - if candidate.is_empty() { - Err(format!("{context}.{field} must not be empty")) - } else { - Ok(candidate) - } - }) -} - -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 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), - "distributionPoint" => Ok(SccmRole::DistributionPoint), - 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" if rotation.get("value").is_none() => Ok(SccmRotation::Current), - "current" => Err("current rotation must not contain a value".to_owned()), - "lo_" if rotation.get("value").is_none() => Ok(SccmRotation::LoUnderscore), - "lo_" => Err("lo_ rotation must not contain a value".to_owned()), - "numbered" => serde_json::from_value(json!({ - "kind": "numbered", - "value": rotation["value"].clone(), - })) - .map_err(|error| format!("numbered rotation is noncanonical: {error}")), - "timestamped" => serde_json::from_value(json!({ - "kind": "timestamped", - "value": rotation["value"].clone(), - })) - .map_err(|error| format!("timestamped rotation is noncanonical: {error}")), - other => Err(format!("unsupported fixture rotation {other}")), - } -} - -fn allowed_source(source_id: &str, role: &str, basename: &str) -> bool { - matches!( - (source_id, role, basename), - ("server-dp-distribution", "siteServer", "distmgr.log") - | ("server-dp-distribution", "siteServer", "PkgXferMgr.log") - | ( - "server-dp-distribution", - "distributionPoint", - "SMSDPProv.log" - ) - | ("server-dp-distribution", "distributionPoint", "PullDP.log") - | ("server-dp-serve", "distributionPoint", "SMSdpmon.log") - | ( - "client-content-control", - "client", - "DataTransferService.log" - ) - ) -} - -fn phase_allowed_for_artifact(artifact: &ParsedArtifact, phase: &str) -> bool { - matches!( - ( - artifact.source_id.as_str(), - artifact.basename.as_str(), - phase - ), - ( - "server-dp-distribution", - "distmgr.log", - "receiveContent" | "distribute" - ) | ("server-dp-distribution", "PkgXferMgr.log", "transfer") - | ( - "server-dp-distribution", - "PullDP.log", - "receiveContent" | "transfer" - ) - | ( - "server-dp-distribution", - "SMSDPProv.log", - "validate" | "makeAvailable" | "serveOrReport" - ) - | ("server-dp-serve", "SMSdpmon.log", "serveOrReport") - ) -} - -#[derive(Debug)] -struct ParsedArtifact { - state: String, - source_id: String, - role: String, - producer_host_handle: String, - basename: String, - workflow_subject_handle: Option, - workflow_subject_basis: Option, - rotation_kind: String, - rotation_lineage: String, - fragment_complete: Option, -} - -#[derive(Debug)] -struct ParsedScenario { - artifacts: BTreeMap, - evidence: BTreeMap<(String, u32, u32), SccmEvidence>, - physical_evidence: BTreeSet<(String, u32, u32)>, - distribution_point_handles: BTreeSet, -} - -fn artifact_applies_to_distribution_point( - artifact: &ParsedArtifact, - distribution_point_handle: &str, -) -> bool { - artifact.workflow_subject_handle.as_deref() == Some(distribution_point_handle) - || (artifact.workflow_subject_handle.is_none() - && artifact.workflow_subject_basis.as_deref() == Some("manifestTopology") - && artifact.role == "siteServer") -} - -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", - "PackageId", - "ContentId", - "ContentVersion", - "SiteCode", - "DpHandle", - "ProfileId", - "ClientHandle", - "RequestId", - ]; - 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 path_segment_is_safe(segment: &str) -> bool { - !segment.is_empty() - && !matches!(segment, "." | "..") - && segment - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) -} - -fn segmented_path_is_safe(path: &str) -> bool { - !path.is_empty() && !path.contains('\\') && path.split('/').all(path_segment_is_safe) -} - -fn source_path_is_bounded(relative_path: &str, basename: &str) -> bool { - relative_path - .strip_prefix("evidence/") - .is_some_and(segmented_path_is_safe) - && relative_path - .rsplit('/') - .next() - .is_some_and(|candidate| candidate == basename) -} - -fn sanitized_source_path_is_bounded(source_path: &str, basename: &str, rotation: &Value) -> bool { - let Some(suffix) = source_path.strip_prefix("SYNTHETIC://") else { - return false; - }; - if !segmented_path_is_safe(suffix) { - return false; - } - let segments = suffix.split('/').collect::>(); - if segments.len() != 3 - || !matches!( - segments[0], - "client-control" - | "default-dp-root" - | "default-site-root" - | "dp-02-root" - | "dp-root" - | "site-root" - ) - || segments[1] != "Logs" - { - return false; - } - - let expected_basename = match rotation["kind"].as_str() { - Some("current") => Some(basename.to_owned()), - Some("lo_") => basename - .strip_suffix(".log") - .map(|stem| format!("{stem}.lo_")), - Some("numbered") => rotation["value"] - .as_u64() - .map(|value| format!("{basename}.{value}")), - Some("timestamped") => rotation["value"] - .as_str() - .map(|value| format!("{basename}.{value}")), - _ => None, - }; - expected_basename.is_some_and(|expected| segments[2].eq_ignore_ascii_case(&expected)) -} - -fn path_fingerprint_is_safe(path_fingerprint: &str) -> bool { - path_fingerprint.len() <= 64 && APPROVED_PATH_FINGERPRINTS.contains(&path_fingerprint) -} - -fn rotation_lineage_is_safe(rotation_lineage: &str) -> bool { - rotation_lineage.len() <= 64 && APPROVED_ROTATION_LINEAGES.contains(&rotation_lineage) -} - -fn transaction_observation_id_is_safe(observation_id: &str) -> bool { - observation_id.len() <= 64 - && observation_id - .as_bytes() - .get(..2) - .is_some_and(|prefix| prefix.iter().all(u8::is_ascii_digit)) - && observation_id.as_bytes().get(2) == Some(&b'-') - && observation_id.as_bytes().get(3..).is_some_and(|suffix| { - !suffix.is_empty() - && suffix - .iter() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-') - }) -} - -fn source_local_observation_id_is_safe(observation_id: &str) -> bool { - observation_id.len() <= 64 - && (observation_id - .strip_prefix("client-control-") - .is_some_and(|suffix| { - suffix.len() == 2 && suffix.bytes().all(|byte| byte.is_ascii_digit()) - }) - || observation_id - .strip_prefix("rotation-") - .is_some_and(|suffix| { - suffix.split_once('-').is_some_and(|(ordinal, label)| { - ordinal.len() == 2 - && ordinal.bytes().all(|byte| byte.is_ascii_digit()) - && !label.is_empty() - && label.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' - }) - }) - })) -} - -fn raw_fixture_bytes_are_sanitized(content: &str) -> bool { - !content.is_empty() - && content.lines().all(|line| { - let lower = line.to_ascii_lowercase(); - !line.is_empty() - && line.contains("SYNTHETIC FIXTURE") - && !line.contains(['@', '\\']) - && !lower.contains("/users/") - && !lower.contains("secret=") - && !lower.contains("password=") - && !lower.contains("token=") - }) -} - -fn artifact_id_is_safe(artifact_id: &str) -> bool { - artifact_id.len() <= 128 && path_segment_is_safe(artifact_id) -} - -fn request_id_is_safe(request_id: &str) -> bool { - request_id - .strip_prefix("client-request-") - .is_some_and(|suffix| suffix.len() <= 64 && path_segment_is_safe(suffix)) -} - -fn coverage_request_reason(artifact: &ParsedArtifact) -> Option<&'static str> { - match artifact.state.as_str() { - "absent" => Some("coverageAbsent"), - "accessDenied" => Some("coverageAccessDenied"), - "capped" => Some("coverageCapped"), - "parseFailed" => Some("coverageMalformed"), - _ if artifact.fragment_complete == Some(false) => Some("coverageRotationSplit"), - _ => None, - } -} - -fn artifact_has_incomplete_coverage(artifact: &ParsedArtifact) -> bool { - artifact.state != "captured" || artifact.fragment_complete == Some(false) -} - -fn validate_manifest( - scenario_root: &std::path::Path, - manifest: &Value, -) -> Result> { - let mut failures = Vec::new(); - reject_unknown_fields( - manifest, - &[ - "sccmManifestVersion", - "proposalOnly", - "syntheticFixture", - "bundle", - "topology", - "artifacts", - ], - "manifest", - &mut failures, - ); - reject_unknown_fields( - &manifest["bundle"], - &["bundleRole", "workflow", "capturedUtc"], - "bundle", - &mut failures, - ); - reject_unknown_fields( - &manifest["topology"], - &[ - "siteCode", - "distributionPointHandle", - "distributionPointHandles", - "rolesObserved", - ], - "topology", - &mut failures, - ); - if manifest["sccmManifestVersion"] != 1 - || manifest["proposalOnly"] != true - || manifest["syntheticFixture"] != true - || manifest["bundle"]["bundleRole"] != "server" - || manifest["bundle"]["workflow"] != "distributionPoint" - { - failures - .push("manifest does not retain the versioned synthetic server boundary".to_owned()); - } - if manifest["topology"]["siteCode"] != EXACT_SITE - || manifest["topology"]["distributionPointHandle"] != EXACT_DP - { - failures.push("manifest topology is not the exact synthetic LAB DP".to_owned()); - } - let mut distribution_point_handles = Vec::new(); - match required_array( - &manifest["topology"], - "distributionPointHandles", - "topology", - ) { - Ok(values) => { - for value in values { - match value.as_str() { - Some(handle) - if !handle.is_empty() && matches!(handle, EXACT_DP | EXACT_DP_02) => - { - distribution_point_handles.push(handle.to_owned()); - } - Some(handle) => failures.push(format!( - "distributionPointHandles contains unknown handle {handle}" - )), - None => failures.push( - "distributionPointHandles entries must be nonempty strings".to_owned(), - ), - } - } - } - Err(error) => failures.push(error), - } - let original_handle_order = distribution_point_handles.clone(); - distribution_point_handles.sort(); - distribution_point_handles.dedup(); - if distribution_point_handles != original_handle_order - || !distribution_point_handles - .iter() - .any(|handle| handle == EXACT_DP) - || distribution_point_handles - .iter() - .any(|handle| !handle.starts_with("safe:dp:")) - { - failures.push( - "distributionPointHandles must be sorted, unique, opaque, and include the primary DP" - .to_owned(), - ); - } - let distribution_point_handles = distribution_point_handles - .into_iter() - .collect::>(); - - let mut roles = Vec::new(); - match required_array(&manifest["topology"], "rolesObserved", "topology") { - Ok(values) => { - for value in values { - match value.as_str() { - Some(role) if matches!(role, "distributionPoint" | "siteServer") => { - roles.push(role); - } - Some(role) => { - failures.push(format!("rolesObserved contains unsupported role {role}")) - } - None => failures.push( - "rolesObserved entries must be strings in the allowed role set".to_owned(), - ), - } - } - } - Err(error) => failures.push(error), - } - let mut sorted_roles = roles.clone(); - sorted_roles.sort_unstable(); - sorted_roles.dedup(); - if roles != sorted_roles || !roles.contains(&"siteServer") { - failures - .push("rolesObserved must be sorted, unique, and retain the site server".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 deterministically sorted by artifactId".to_owned()); - } - - let mut parsed_artifacts = BTreeMap::new(); - let mut evidence_by_reference = BTreeMap::new(); - let mut physical_evidence_by_reference = BTreeSet::new(); - let mut relative_paths = BTreeSet::new(); - let mut physical_source_identities = BTreeSet::new(); - let mut path_fingerprints = BTreeSet::new(); - for artifact in artifacts { - let artifact_id = match required_nonempty_string(artifact, "artifactId", "artifact") { - Ok(value) => value, - Err(error) => { - failures.push(error); - continue; - } - }; - if !artifact_id_is_safe(artifact_id) { - failures.push(format!( - "artifact {artifact_id} does not use a bounded stable artifact ID" - )); - } - let context = format!("artifact {artifact_id}"); - let source_id = match required_string(artifact, "sourceId", &context) { - Ok(value) => value, - Err(error) => { - failures.push(error); - continue; - } - }; - let role = match required_string(artifact, "producerRole", &context) { - Ok(value) => value, - Err(error) => { - failures.push(error); - continue; - } - }; - let basename = match required_string(artifact, "originalBasename", &context) { - Ok(value) => value, - Err(error) => { - failures.push(error); - continue; - } - }; - let state = match required_string(artifact, "captureState", &context) { - Ok(value) => value, - Err(error) => { - failures.push(error); - continue; - } - }; - reject_unknown_fields( - artifact, - &[ - "artifactId", - "sourceId", - "producerRole", - "producerHostHandle", - "workflowSubjectRole", - "workflowSubjectHandle", - "workflowSubjectBasis", - "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, - ); - } - if !allowed_source(source_id, role, basename) { - failures.push(format!( - "{artifact_id} has an uncatalogued source/producer/basename combination" - )); - } - if role != "client" && !roles.contains(&role) { - failures.push(format!( - "{artifact_id} producer role {role} is absent from rolesObserved" - )); - } - let workflow_subject_handle = artifact["workflowSubjectHandle"].as_str(); - let workflow_subject_basis = artifact["workflowSubjectBasis"].as_str(); - if artifact["workflowSubjectRole"] != "distributionPoint" - || match (workflow_subject_handle, workflow_subject_basis) { - (Some(handle), None) => !distribution_point_handles.contains(handle), - (None, Some("manifestTopology")) => { - role != "siteServer" || distribution_point_handles.len() < 2 - } - _ => true, - } - { - failures.push(format!( - "{artifact_id} loses the distribution-point workflow subject" - )); - } - let producer_host_handle = artifact["producerHostHandle"].as_str(); - let producer_matches_role = match role { - "siteServer" => producer_host_handle == Some(EXACT_SITE_SERVER), - "client" => producer_host_handle == Some(EXACT_CLIENT), - "distributionPoint" => { - producer_host_handle.is_some_and(|value| distribution_point_handles.contains(value)) - } - _ => false, - }; - if !producer_matches_role { - failures.push(format!( - "{artifact_id} producer handle is not in the exact role-specific namespace" - )); - } - if role == "distributionPoint" - && (workflow_subject_handle.is_none() - || artifact["producerHostHandle"] != artifact["workflowSubjectHandle"]) - { - failures.push(format!( - "{artifact_id} DP producer does not match its exact workflow subject" - )); - } - let rotation_kind = artifact["rotation"]["kind"].as_str(); - let rotation_lineage = match required_nonempty_string( - &artifact["rotation"], - "lineageId", - &context, - ) { - Ok(value) => { - if !rotation_lineage_is_safe(value) { - failures.push(format!( - "{artifact_id} rotation lineage is not a bounded declared synthetic identity" - )); - } - value.to_owned() - } - Err(error) => { - failures.push(error); - String::new() - } - }; - let physical_capture = matches!(state, "captured" | "capped" | "parseFailed"); - 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 { - failures.push(format!( - "{artifact_id} was collected after the canonical bundle capture" - )); - } - Some(value) - } - Err(error) => { - failures.push(error); - None - } - }; - let encoding = artifact["encoding"].as_str(); - if physical_capture && encoding.is_none_or(str::is_empty) { - failures.push(format!( - "{artifact_id} physical capture lacks encoding provenance" - )); - } - let fragment_complete = if physical_capture { - match required_bool(&artifact["rotation"], "fragmentComplete", &context) { - Ok(value) => Some(value), - Err(error) => { - failures.push(error); - None - } - } - } else { - None - }; - let path_fingerprint = artifact["pathFingerprint"].as_str(); - let sanitized_source_path = artifact["sanitizedSourcePath"].as_str(); - if !path_fingerprint.is_some_and(path_fingerprint_is_safe) - || !sanitized_source_path.is_some_and(|value| { - sanitized_source_path_is_bounded(value, basename, &artifact["rotation"]) - }) - { - 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} duplicates another physical path fingerprint" - )); - } - let rotation_value = artifact["rotation"]["value"] - .as_str() - .map(str::to_owned) - .or_else(|| { - artifact["rotation"]["value"] - .as_u64() - .map(|value| value.to_string()) - }) - .unwrap_or_default(); - if let (Some(producer), Some(source_path), Some(rotation_kind)) = ( - artifact["producerHostHandle"].as_str(), - sanitized_source_path, - rotation_kind, - ) { - let physical_identity = ( - producer.to_owned(), - source_path.to_ascii_lowercase(), - basename.to_ascii_lowercase(), - rotation_kind.to_owned(), - rotation_value.to_ascii_lowercase(), - ); - if !physical_source_identities.insert(physical_identity) { - failures.push(format!( - "{artifact_id} duplicates one physical source for another workflow subject" - )); - } - } - if artifact["sourceKind"] != "ccmLog" - || artifact["sourceVersion"].as_str() != Some(EXACT_SOURCE_VERSION) - { - failures.push(format!( - "{artifact_id} is outside the synthetic CCM/profile source boundary" - )); - } - - 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_from_manifest(&artifact["rotation"]) { - Ok(value) => value, - Err(error) => { - failures.push(format!("{artifact_id}: {error}")); - continue; - } - }; - - if physical_capture { - 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 !relative_paths.insert(relative_path.to_ascii_lowercase()) { - failures.push(format!( - "{artifact_id} collides with another physical evidence destination" - )); - } - let fixture_path = scenario_root.join(relative_path); - let bytes = 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 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-limit provenance" - )); - } - let content = match std::str::from_utf8(&bytes) { - Ok(value) => value, - Err(error) => { - failures.push(format!( - "{artifact_id} is not valid UTF-8 under its declared encoding: {error}" - )); - "" - } - }; - if !raw_fixture_bytes_are_sanitized(content) { - failures.push(format!( - "{artifact_id} contains non-synthetic or identity-bearing raw fixture bytes" - )); - } - for (line_index, _) in content.lines().enumerate() { - let Ok(line_number) = u32::try_from(line_index + 1) else { - failures.push(format!( - "{artifact_id} has more physical lines than an evidence reference can address" - )); - break; - }; - physical_evidence_by_reference.insert(( - artifact_id.to_owned(), - line_number, - line_number, - )); - } - if matches!(state, "captured" | "capped") { - 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: Some( - required_string(artifact, "collectedUtc", &context) - .unwrap_or_default() - .to_owned(), - ), - rotation: rotation_model.clone(), - coverage: coverage_model.clone(), - encoding: encoding.map(str::to_owned), - }; - let normalized = normalize_ccm_artifact(artifact_model, content); - if fragment_complete == Some(false) && !normalized.is_empty() { - failures.push(format!( - "{artifact_id} exposes a logical record from an incomplete rotation 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() - { - failures.push(format!( - "{artifact_id} has unusable timestamp provenance in a transaction-capable record" - )); - } else if record - .timestamp - .utc_millis - .is_some_and(|value| value > captured_utc) - { - failures.push(format!( - "{artifact_id} cites evidence later than the canonical bundle capture" - )); - } - if artifact_collected_utc.is_none() - || artifact_collected_utc.is_some_and(|value| value > captured_utc) - || 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 incoherent evidence/artifact/bundle 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) => { - let record_dp_handle = fields.get("DpHandle").map(String::as_str); - if workflow_subject_handle - .is_some_and(|handle| record_dp_handle != Some(handle)) - || workflow_subject_basis == Some("manifestTopology") - && record_dp_handle.is_none_or(|handle| { - !distribution_point_handles.contains(handle) - }) - { - failures.push(format!( - "{artifact_id} record escapes its declared workflow-subject scope" - )); - } - let identity_fields_are_safe = match role { - "client" => { - fields.get("ClientHandle").map(String::as_str) - == Some(EXACT_CLIENT) - && fields - .get("RequestId") - .is_some_and(|value| request_id_is_safe(value)) - } - "siteServer" | "distributionPoint" => { - !fields.contains_key("ClientHandle") - && !fields.contains_key("RequestId") - } - _ => false, - }; - if !identity_fields_are_safe { - failures.push(format!( - "{artifact_id} exposes identity-bearing fields outside the approved opaque role namespace" - )); - } - } - 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.clone()).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_handle: producer_host_handle.unwrap_or_default().to_owned(), - basename: basename.to_owned(), - workflow_subject_handle: workflow_subject_handle.map(str::to_owned), - workflow_subject_basis: workflow_subject_basis.map(str::to_owned), - rotation_kind: artifact["rotation"]["kind"] - .as_str() - .unwrap_or_default() - .to_owned(), - rotation_lineage, - fragment_complete, - }, - ) - .is_some() - { - failures.push(format!("duplicate artifactId {artifact_id}")); - } - } - - if failures.is_empty() { - Ok(ParsedScenario { - artifacts: parsed_artifacts, - evidence: evidence_by_reference, - physical_evidence: physical_evidence_by_reference, - distribution_point_handles, - }) - } else { - Err(failures) - } -} - -fn evidence_for<'a>( - parsed: &'a ParsedScenario, - reference: &Value, - context: &str, -) -> Result<&'a SccmEvidence, String> { - let key = evidence_reference_key(reference, context)?; - parsed.evidence.get(&key).ok_or_else(|| { - format!( - "{context} does not cite a physical logical record: {}:{}-{}", - key.0, key.1, key.2 - ) - }) -} - -fn physical_evidence_for( - parsed: &ParsedScenario, - reference: &Value, - context: &str, -) -> Result<(), String> { - let key = evidence_reference_key(reference, context)?; - if parsed.physical_evidence.contains(&key) { - Ok(()) - } else { - Err(format!( - "{context} does not cite an exact physical line: {}:{}-{}", - key.0, key.1, key.2 - )) - } -} - -fn evidence_reference_key(reference: &Value, context: &str) -> Result<(String, u32, u32), 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"))?; - Ok((artifact_id.to_owned(), line_start, line_end)) -} - -fn exact_key_fields( - key: &Value, - context: &str, - distribution_point_handles: &BTreeSet, -) -> Result, String> { - let mut fields = BTreeMap::new(); - for (json_field, record_field) in [ - ("packageId", "PackageId"), - ("contentId", "ContentId"), - ("contentVersion", "ContentVersion"), - ("siteCode", "SiteCode"), - ("distributionPointHandle", "DpHandle"), - ("extractionProfileId", "ProfileId"), - ] { - let value = if json_field == "contentVersion" { - key[json_field] - .as_u64() - .map(|value| value.to_string()) - .ok_or_else(|| format!("{context}.{json_field} must be a u64"))? - } else { - required_string(key, json_field, context)?.to_owned() - }; - fields.insert(record_field, value); - } - if fields["SiteCode"] != EXACT_SITE - || !distribution_point_handles.contains(&fields["DpHandle"]) - || fields["ProfileId"] != EXACT_PROFILE - || key["confidence"] != "exact" - { - return Err(format!( - "{context} is outside the exact synthetic key 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, - ); - reject_unknown_fields( - &expected["analysisContract"], - &[ - "independentReducer", - "consumesClientOutput", - "crossSideCorrelationPerformed", - ], - "analysisContract", - &mut failures, - ); - reject_unknown_fields( - &expected["extractionProfile"], - &["selectionState", "profileId", "validatedRole"], - "extractionProfile", - &mut failures, - ); - reject_unknown_fields( - &expected["roleAssessment"], - &[ - "distributionPointObserved", - "roleAbsentInferred", - "missingDefaultPathInterpretation", - ], - "roleAssessment", - &mut failures, - ); - reject_unknown_fields( - &expected["correlationHandoff"], - &["issue", "performed", "timeOnlyEligible"], - "correlationHandoff", - &mut failures, - ); - if expected["contractState"] != "proposedPendingReviewed318And335" - || expected["workflow"] != "distributionPoint" - || 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()); - } - if expected["stateChain"] - .as_array() - .and_then(|values| values.iter().map(Value::as_str).collect::>>()) - .as_deref() - != Some(STATE_CHAIN) - { - failures.push("expected state chain does not match Task 5".to_owned()); - } - if expected["extractionProfile"]["profileId"] != EXACT_PROFILE - || expected["extractionProfile"]["selectionState"] != "selectedSynthetic" - || expected["extractionProfile"]["validatedRole"] != "distributionPoint" - { - failures - .push("expected output lacks the versioned synthetic extraction profile".to_owned()); - } - if expected["roleAssessment"]["roleAbsentInferred"] != false - || expected["roleAssessment"]["missingDefaultPathInterpretation"] != "sourceCoverageOnly" - { - failures.push("expected output infers role state from source coverage".to_owned()); - } - let distribution_point_observed = match required_bool( - &expected["roleAssessment"], - "distributionPointObserved", - "roleAssessment", - ) { - Ok(value) => Some(value), - Err(error) => { - failures.push(error); - None - } - }; - - 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 row", &mut failures); - let Ok(artifact_id) = required_string(row, "artifactId", "coverage row") else { - failures.push("coverage row lacks artifactId".to_owned()); - continue; - }; - let Ok(state) = required_string(row, "state", "coverage row") else { - failures.push(format!("{artifact_id} coverage row lacks state")); - continue; - }; - coverage_order.push(artifact_id.to_owned()); - 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(); - if coverage_order != sorted_coverage { - failures.push("coverage rows are not deterministically sorted".to_owned()); - } - if declared_coverage != expected_coverage { - failures.push(format!( - "coverage is not the exact physical manifest projection: {declared_coverage:?} != {expected_coverage:?}" - )); - } - - 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_observation_ids = BTreeSet::new(); - let mut consumed_evidence = BTreeSet::new(); - let mut required_incomplete_requests = BTreeSet::new(); - for transaction in transactions { - let transaction_id = match required_string(transaction, "transactionId", "transaction") { - Ok(value) => value, - Err(error) => { - failures.push(error); - continue; - } - }; - 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"], - &[ - "packageId", - "contentId", - "contentVersion", - "siteCode", - "distributionPointHandle", - "confidence", - "extractionProfileId", - ], - &format!("{transaction_id}.key"), - &mut failures, - ); - let key_fields = match exact_key_fields( - &transaction["key"], - &format!("{transaction_id}.key"), - &parsed.distribution_point_handles, - ) { - Ok(value) => value, - Err(error) => { - failures.push(error); - continue; - } - }; - let required_incomplete_artifact_ids = parsed - .artifacts - .iter() - .filter(|(_, artifact)| { - artifact.source_id == "server-dp-distribution" - && artifact_has_incomplete_coverage(artifact) - && artifact_applies_to_distribution_point(artifact, &key_fields["DpHandle"]) - }) - .map(|(artifact_id, _)| artifact_id.as_str()) - .collect::>(); - let has_unresolved_required_coverage = !required_incomplete_artifact_ids.is_empty(); - let expected_id = format!( - "dp:{}:{}:v{}:{}", - key_fields["PackageId"], - key_fields["ContentId"], - key_fields["ContentVersion"], - key_fields["DpHandle"] - ); - if transaction_id != expected_id { - failures.push(format!( - "{transaction_id} is not 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; - } - }; - if observations.is_empty() { - failures.push(format!( - "{transaction_id} has an exact correlation-eligible key without cited logical records" - )); - } - 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 deterministically sorted" - )); - } - - let mut latest_success: Option = None; - let mut latest_outcome: Option<(&str, bool)> = None; - let mut terminal_success = false; - let mut terminal_success_phase = None; - let mut terminal_failure = false; - let mut terminal_deferred = false; - let mut observed_after_terminal_failure = false; - let mut cites_capped_evidence = false; - let mut previous_utc = i64::MIN; - let mut previous_physical_order: Option<(String, u32)> = None; - let mut previous_phase = 0usize; - for observation in observations { - let observation_id = - match required_nonempty_string(observation, "observationId", transaction_id) { - Ok(value) => value, - Err(error) => { - failures.push(error); - continue; - } - }; - if !seen_observation_ids.insert(observation_id) { - failures.push(format!( - "{transaction_id} contains duplicate observationId {observation_id}" - )); - } - if !transaction_observation_id_is_safe(observation_id) { - failures.push(format!( - "{transaction_id} contains an unbounded or identity-bearing observationId" - )); - } - let phase = required_string(observation, "phase", observation_id).unwrap_or("invalid"); - let disposition = - required_string(observation, "disposition", observation_id).unwrap_or("invalid"); - let terminal = match required_bool(observation, "terminal", observation_id) { - Ok(value) => value, - Err(error) => { - failures.push(error); - 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() { - failures.push(format!("{observation_id} uses unsupported phase {phase}")); - } - if phase_index.is_some_and(|index| index < previous_phase) { - failures.push(format!( - "{transaction_id} phases move backward despite increasing evidence time" - )); - } - 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, - ); - if evidence_reference_key(reference, observation_id) - .is_ok_and(|key| !consumed_evidence.insert(key)) - { - failures.push(format!( - "{transaction_id} consumes one physical evidence reference more than once" - )); - } - let cited_artifact_id = - required_string(reference, "artifactId", observation_id).unwrap_or("invalid"); - match parsed.artifacts.get(cited_artifact_id) { - Some(artifact) - if artifact.role != "client" - && phase_allowed_for_artifact(artifact, phase) - && artifact - .workflow_subject_handle - .as_deref() - .is_none_or(|handle| handle == key_fields["DpHandle"]) => - { - cites_capped_evidence |= artifact.state == "capped"; - } - _ => 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 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}={expected_value}" - )); - } - } - 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 ordered by normalized UTC provenance" - )); - } - let current_physical_order = record - .reference - .line_start - .zip(record.reference.line_end) - .map(|(line_start, line_end)| { - (record.reference.artifact_id.as_str(), line_start, line_end) - }); - if utc == previous_utc - && previous_physical_order.as_ref().is_some_and( - |(previous_artifact_id, previous_line_end)| { - current_physical_order.is_none_or(|(artifact_id, line_start, _)| { - artifact_id != previous_artifact_id - || line_start <= *previous_line_end - }) - }, - ) - { - failures.push(format!( - "{transaction_id} equal-UTC evidence lacks immutable same-artifact line order" - )); - } - previous_utc = utc; - previous_physical_order = current_physical_order - .map(|(artifact_id, _, line_end)| (artifact_id.to_owned(), line_end)); - } - if terminal_failure { - observed_after_terminal_failure = true; - } - match (disposition, terminal) { - ("succeeded", true) => { - latest_success = latest_success.max(phase_index); - if terminal_success_phase.is_some() { - failures.push(format!( - "{transaction_id} contains more than one terminal success" - )); - } - terminal_success_phase = phase_index; - terminal_success = true; - } - ("succeeded", false) => latest_success = latest_success.max(phase_index), - ("failed", true) => terminal_failure = true, - ("deferred" | "retrying", false) => terminal_deferred = true, - _ => failures.push(format!( - "{observation_id} uses an incoherent disposition/terminal pair" - )), - } - latest_outcome = Some((disposition, terminal)); - } - - 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"); - match (state, classification) { - ("succeeded", "success") - if terminal_success - && latest_outcome == Some(("succeeded", true)) - && terminal_success_phase - == STATE_CHAIN - .iter() - .position(|phase| *phase == "serveOrReport") - && computed_last_success == Some("serveOrReport") - && !terminal_failure - && !cites_capped_evidence - && !has_unresolved_required_coverage - && confidence == "high" - && confidence_ceiling == "high" => {} - ("failed", "confirmedFailure") - if terminal_failure - && !terminal_success - && !observed_after_terminal_failure - && !cites_capped_evidence - && !has_unresolved_required_coverage - && confidence == "high" - && confidence_ceiling == "high" => {} - ("deferred", "blockedOrDeferred") - if terminal_deferred - && matches!(latest_outcome, Some(("deferred" | "retrying", false))) - && !terminal_failure - && !terminal_success - && !cites_capped_evidence - && !has_unresolved_required_coverage - && 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 the required terminal evidence" - )), - } - - let mut gap_ids = Vec::new(); - match required_array(transaction, "coverageGapArtifactIds", transaction_id) { - Ok(values) => { - for value in values { - match value.as_str() { - Some(artifact_id) if !artifact_id.is_empty() => gap_ids.push(artifact_id), - Some(_) => failures.push(format!( - "{transaction_id} coverage gap artifact ID must not be empty" - )), - None => failures.push(format!( - "{transaction_id} coverage gap artifact IDs must be strings" - )), - } - } - } - Err(error) => failures.push(error), - } - let mut sorted_gap_ids = gap_ids.clone(); - sorted_gap_ids.sort_unstable(); - sorted_gap_ids.dedup(); - if gap_ids != sorted_gap_ids { - failures.push(format!( - "{transaction_id} coverage gaps must be sorted and unique" - )); - } - let declared_gap_ids = gap_ids.iter().copied().collect::>(); - if declared_gap_ids != required_incomplete_artifact_ids { - failures.push(format!( - "{transaction_id} coverage gaps are not the exact required DP-bound source gaps" - )); - } - for artifact_id in &gap_ids { - match parsed.artifacts.get(*artifact_id) { - Some(artifact) if artifact_has_incomplete_coverage(artifact) => {} - _ => failures.push(format!( - "{transaction_id} coverage gap {artifact_id} is absent or complete" - )), - } - } - 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_has_incomplete_coverage(artifact) - }) - { - failures.push(format!( - "{transaction_id} incomplete state lacks a bounded noncomplete next source" - )); - } - let expected_gap_ids = parsed - .artifacts - .iter() - .filter(|(_, artifact)| { - Some(artifact.source_id.as_str()) == next_source - && artifact_has_incomplete_coverage(artifact) - && artifact_applies_to_distribution_point(artifact, &key_fields["DpHandle"]) - }) - .map(|(artifact_id, _)| artifact_id.as_str()) - .collect::>(); - if declared_gap_ids.is_empty() || declared_gap_ids != expected_gap_ids { - failures.push(format!( - "{transaction_id} incomplete state lacks the exact physical coverage gaps" - )); - } - for artifact_id in declared_gap_ids { - let Some(artifact) = parsed.artifacts.get(artifact_id) else { - continue; - }; - match coverage_request_reason(artifact) { - Some(reason_code) => { - required_incomplete_requests - .insert((artifact.source_id.clone(), reason_code.to_owned())); - } - None => failures.push(format!( - "{transaction_id} gap {artifact_id} has no bounded artifact-request reason" - )), - } - } - } else if !transaction["nextSourceId"].is_null() { - failures.push(format!( - "{transaction_id} terminal/deferred state invents a next source" - )); - } - } - - 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 deterministically sorted".to_owned()); - } - for observation in source_local { - let observation_id = match required_nonempty_string( - observation, - "observationId", - "sourceLocalObservation", - ) { - Ok(value) => value, - Err(error) => { - failures.push(error); - continue; - } - }; - if !seen_observation_ids.insert(observation_id) { - failures.push(format!( - "source-local observations contain duplicate observationId {observation_id}" - )); - } - if !source_local_observation_id_is_safe(observation_id) { - failures.push("source-local observationId is unbounded or identity-bearing".to_owned()); - } - 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.get("key").is_some() - || observation["correlationEligible"] != false - || observation["confidence"] != "low" - || observation["confidenceCeiling"] != "low" - { - failures.push(format!( - "{observation_id} is not an explicitly noncorrelatable source-local observation" - )); - } - let mut artifact_ids = Vec::new(); - match required_array(observation, "artifactIds", observation_id) { - Ok(values) => { - for value in values { - match value.as_str() { - Some(artifact_id) if !artifact_id.is_empty() => { - artifact_ids.push(artifact_id); - } - Some(_) => { - failures.push(format!("{observation_id} artifact ID must not be empty")) - } - None => { - failures.push(format!("{observation_id} artifact IDs must be strings")) - } - } - } - } - Err(error) => failures.push(error), - } - let mut sorted_artifact_ids = artifact_ids.clone(); - sorted_artifact_ids.sort_unstable(); - let unique_artifact_ids = artifact_ids.iter().copied().collect::>(); - if artifact_ids.is_empty() - || artifact_ids != sorted_artifact_ids - || unique_artifact_ids.len() != artifact_ids.len() - { - failures.push(format!( - "{observation_id} lacks sorted physical artifact provenance" - )); - } - for artifact_id in &artifact_ids { - if !parsed.artifacts.contains_key(*artifact_id) { - failures.push(format!( - "{observation_id} cites unknown physical artifact {artifact_id}" - )); - } - } - let references = match required_array(observation, "evidence", observation_id) { - Ok(values) if !values.is_empty() => values, - Ok(_) => { - failures.push(format!( - "{observation_id} has no cited physical source-local evidence" - )); - &[] - } - Err(error) => { - failures.push(error); - &[] - } - }; - let mut cited_artifact_ids = BTreeSet::new(); - let mut reference_order = Vec::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", - &format!("{observation_id}.evidence"), - ) { - cited_artifact_ids.insert(artifact_id); - if !unique_artifact_ids.contains(artifact_id) { - failures.push(format!( - "{observation_id} cites evidence outside its physical artifact set" - )); - } - } - if let Ok(key) = evidence_reference_key(reference, observation_id) { - reference_order.push(key.clone()); - if !consumed_evidence.insert(key) { - failures.push(format!( - "{observation_id} consumes one physical evidence reference more than once" - )); - } - } - if let Err(error) = physical_evidence_for(parsed, reference, observation_id) { - failures.push(error); - } - } - let mut sorted_reference_order = reference_order.clone(); - sorted_reference_order.sort(); - if reference_order != sorted_reference_order { - failures.push(format!( - "{observation_id} physical evidence is not canonically ordered" - )); - } - let artifacts = artifact_ids - .iter() - .filter_map(|artifact_id| parsed.artifacts.get(*artifact_id)) - .collect::>(); - let semantic_match = match classification { - Some("ignoredClientEvidence") => { - cited_artifact_ids == unique_artifact_ids - && artifacts.iter().all(|artifact| { - artifact.role == "client" - && artifact.source_id == "client-content-control" - && matches!(artifact.state.as_str(), "captured" | "capped") - }) - } - Some("rotationSplit") => { - let source_ids = artifacts - .iter() - .map(|artifact| artifact.source_id.as_str()) - .collect::>(); - let roles = artifacts - .iter() - .map(|artifact| artifact.role.as_str()) - .collect::>(); - let producers = artifacts - .iter() - .map(|artifact| artifact.producer_host_handle.as_str()) - .collect::>(); - let basenames = artifacts - .iter() - .map(|artifact| artifact.basename.to_ascii_lowercase()) - .collect::>(); - let workflow_subject_handles = artifacts - .iter() - .map(|artifact| artifact.workflow_subject_handle.as_deref()) - .collect::>(); - let workflow_subject_bases = artifacts - .iter() - .map(|artifact| artifact.workflow_subject_basis.as_deref()) - .collect::>(); - let lineages = artifacts - .iter() - .map(|artifact| artifact.rotation_lineage.as_str()) - .collect::>(); - let rotation_kinds = artifacts - .iter() - .map(|artifact| artifact.rotation_kind.as_str()) - .collect::>(); - cited_artifact_ids == unique_artifact_ids - && artifacts.len() >= 2 - && source_ids.len() == 1 - && roles.len() == 1 - && producers.len() == 1 - && basenames.len() == 1 - && workflow_subject_handles.len() == 1 - && workflow_subject_bases.len() == 1 - && lineages.len() == 1 - && lineages.first().is_some_and(|lineage| !lineage.is_empty()) - && rotation_kinds.len() >= 2 - && artifacts.iter().all(|artifact| { - artifact.role != "client" - && matches!(artifact.state.as_str(), "captured" | "capped") - && artifact.fragment_complete == Some(false) - }) - } - Some("malformedEvidence") => { - cited_artifact_ids == unique_artifact_ids - && !artifacts.is_empty() - && artifacts.iter().all(|artifact| { - artifact.role != "client" && artifact.state == "parseFailed" - }) - } - _ => false, - }; - if !semantic_match { - failures.push(format!( - "{observation_id} classification is detached from exact physical coverage semantics" - )); - } - } - - let unconsumed_normalized_evidence = parsed - .evidence - .keys() - .filter(|key| !consumed_evidence.contains(*key)) - .collect::>(); - if !unconsumed_normalized_evidence.is_empty() { - failures.push(format!( - "normalized logical records lack an explicit transaction or source-local classification: {unconsumed_normalized_evidence:?}" - )); - } - - 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 { - let source_id = required_string(request, "sourceId", "artifactRequest") - .unwrap_or("invalid") - .to_owned(); - let reason_code = required_string(request, "reasonCode", "artifactRequest") - .unwrap_or("invalid") - .to_owned(); - reject_unknown_fields( - request, - &["sourceId", "reasonCode"], - "artifactRequest", - &mut failures, - ); - request_order.push((source_id.clone(), reason_code.clone())); - if !matches!( - source_id.as_str(), - "server-dp-distribution" | "server-dp-serve" - ) || !matches!( - reason_code.as_str(), - "coverageAbsent" - | "coverageAccessDenied" - | "coverageCapped" - | "coverageMalformed" - | "coverageRotationSplit" - ) || request.get("reason").is_some() - { - failures.push(format!( - "artifact request is not a bounded versioned source/reason code: {source_id}/{reason_code}" - )); - } - let matching_coverage = parsed.artifacts.values().any(|artifact| { - artifact.source_id == source_id - && match reason_code.as_str() { - "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 !matching_coverage { - failures.push(format!( - "artifact request {source_id}/{reason_code} lacks matching noncomplete coverage" - )); - } - } - let mut sorted_request_order = request_order.clone(); - sorted_request_order.sort_unstable(); - let declared_requests = request_order.iter().cloned().collect::>(); - if request_order != sorted_request_order || declared_requests.len() != request_order.len() { - failures.push("artifact requests are not sorted and unique".to_owned()); - } - let expected_requests = parsed - .artifacts - .values() - .filter(|artifact| artifact.role != "client") - .filter_map(|artifact| { - coverage_request_reason(artifact) - .map(|reason| (artifact.source_id.clone(), reason.to_owned())) - }) - .collect::>(); - if declared_requests != expected_requests { - failures.push(format!( - "artifact requests are not the exact bounded coverage projection: {declared_requests:?} != {expected_requests:?}" - )); - } - if !required_incomplete_requests.is_subset(&declared_requests) { - failures.push( - "incomplete transactions lack requests matching their exact physical gaps".to_owned(), - ); - } - - if expected["clientCausalClaims"] != json!([]) - || expected["correlationHandoff"]["issue"] != "#333" - || expected["correlationHandoff"]["performed"] != false - || expected["correlationHandoff"]["timeOnlyEligible"] != false - { - failures.push( - "expected output makes or enables a premature cross-side causal claim".to_owned(), - ); - } - - if scenario == "absent-dp" - && (distribution_point_observed != Some(true) || !transactions.is_empty()) - { - failures.push("absent-dp must retain the observed role without a diagnosis".to_owned()); - } - if scenario == "client-only-looking-request" - && (!transactions.is_empty() - || !parsed - .artifacts - .values() - .any(|artifact| artifact.role == "client")) - { - failures.push("client-only evidence entered a DP transaction".to_owned()); - } - if scenario == "rotation-boundary" && !transactions.is_empty() { - failures.push("rotation fragments formed a DP transaction".to_owned()); - } - if scenario == "content-version-mismatch" { - let versions = transactions - .iter() - .filter_map(|transaction| transaction["key"]["contentVersion"].as_u64()) - .collect::>(); - let dp_handles = transactions - .iter() - .filter_map(|transaction| { - transaction["key"]["distributionPointHandle"] - .as_str() - .map(str::to_owned) - }) - .collect::>(); - if versions != BTreeSet::from([1, 2]) - || dp_handles - != BTreeSet::from([ - "safe:dp:lab-dp-01".to_owned(), - "safe:dp:lab-dp-02".to_owned(), - ]) - || transactions.len() != 3 - { - failures.push( - "content/version/DP topology did not remain three exact transactions".to_owned(), - ); - } - } - - let topology_distribution_point_observed = manifest["topology"]["rolesObserved"] - .as_array() - .is_some_and(|roles| roles.iter().any(|role| role == "distributionPoint")); - if distribution_point_observed != Some(topology_distribution_point_observed) { - failures.push("role assessment is not an exact topology projection".to_owned()); - } - - if failures.is_empty() { - Ok(()) - } else { - Err(failures) - } -} - -fn validate_scenario_values( - scenario: &str, - manifest: &Value, - expected: &Value, -) -> Result<(), Vec> { - let scenario_root = corpus_root().join(scenario); - let parsed = validate_manifest(&scenario_root, manifest)?; - validate_expected(scenario, manifest, expected, &parsed) -} - -#[test] -fn distribution_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 Task 5 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"))); - } -} - -fn mutation_was_accepted(scenario: &str, manifest: &Value, expected: &Value) -> bool { - validate_scenario_values(scenario, manifest, expected).is_ok() -} - -struct TemporaryScenario { - root: std::path::PathBuf, -} - -impl Drop for TemporaryScenario { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.root); - } -} - -fn copy_fixture_tree(source: &std::path::Path, destination: &std::path::Path) { - std::fs::create_dir_all(destination).expect("temporary fixture directory is created"); - for entry in std::fs::read_dir(source).expect("fixture directory is readable") { - let entry = entry.expect("fixture directory entry is readable"); - let source_path = entry.path(); - let destination_path = destination.join(entry.file_name()); - if source_path.is_dir() { - copy_fixture_tree(&source_path, &destination_path); - } else { - std::fs::copy(&source_path, &destination_path) - .expect("fixture file is copied into the temporary scenario"); - } - } -} - -fn temporary_scenario(scenario: &str) -> TemporaryScenario { - static NEXT_TEMP_SCENARIO: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let sequence = NEXT_TEMP_SCENARIO.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let root = std::env::temp_dir().join(format!( - "cmtraceopen-sccm-329-{}-{scenario}-{sequence}", - std::process::id() - )); - copy_fixture_tree(&corpus_root().join(scenario), &root); - TemporaryScenario { root } -} - -fn mutation_at_root_was_accepted( - scenario: &str, - scenario_root: &std::path::Path, - manifest: &Value, - expected: &Value, -) -> bool { - validate_manifest(scenario_root, manifest) - .and_then(|parsed| validate_expected(scenario, manifest, expected, &parsed)) - .is_ok() -} - -fn replace_fixture_text( - scenario_root: &std::path::Path, - relative_path: &str, - original: &str, - replacement: &str, -) { - let path = scenario_root.join(relative_path); - let contents = std::fs::read_to_string(&path).expect("temporary fixture is readable"); - assert_eq!( - contents.matches(original).count(), - 1, - "fixture mutation must identify exactly one raw marker" - ); - std::fs::write(&path, contents.replacen(original, replacement, 1)) - .expect("temporary fixture mutation is written"); -} - -fn append_fixture_record(scenario_root: &std::path::Path, relative_path: &str, record: &str) { - let path = scenario_root.join(relative_path); - let mut contents = std::fs::read_to_string(&path).expect("temporary fixture is readable"); - if !contents.ends_with('\n') { - contents.push('\n'); - } - contents.push_str(record); - contents.push('\n'); - std::fs::write(&path, contents).expect("temporary fixture mutation is written"); -} - -fn refresh_artifact_bytes( - manifest: &mut Value, - artifact_index: usize, - scenario_root: &std::path::Path, -) { - let relative_path = manifest["artifacts"][artifact_index]["relativePath"] - .as_str() - .expect("physical artifact has a relative path"); - let byte_count = std::fs::metadata(scenario_root.join(relative_path)) - .expect("mutated physical artifact is readable") - .len(); - manifest["artifacts"][artifact_index]["bytesCopied"] = json!(byte_count); -} - -fn remove_physical_capture_fields(artifact: &mut Value) { - let artifact = artifact - .as_object_mut() - .expect("fixture artifact is an object"); - for field in ["encoding", "collectionLimit", "bytesCopied", "relativePath"] { - artifact.remove(field); - } - artifact["rotation"] - .as_object_mut() - .expect("fixture rotation is an object") - .remove("fragmentComplete"); -} - -#[test] -fn exact_content_version_dp_topology_and_terminal_evidence_fail_closed() { - let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut version_alias = healthy_expected.clone(); - version_alias["transactions"][0]["key"]["contentVersion"] = json!(2); - if mutation_was_accepted("healthy-package", &healthy_manifest, &version_alias) { - accepted.push("transaction content version diverged from cited evidence"); - } - - let mut dp_alias = healthy_expected.clone(); - dp_alias["transactions"][0]["key"]["distributionPointHandle"] = json!("safe:dp:lab-dp-02"); - if mutation_was_accepted("healthy-package", &healthy_manifest, &dp_alias) { - accepted.push("transaction DP topology diverged from cited evidence"); - } - - let mut time_only_cause = healthy_expected.clone(); - time_only_cause["clientCausalClaims"] = - json!(["A same-time client request proves the DP caused the failure."]); - if mutation_was_accepted("healthy-package", &healthy_manifest, &time_only_cause) { - accepted.push("time-only client/DP causality was admitted"); - } - - let failure_manifest = - read_json("distribution-failure", "manifest.json").expect("manifest loads"); - let mut failure_expected = - read_json("distribution-failure", "expected.json").expect("expected loads"); - failure_expected["transactions"][0]["observations"][1]["terminal"] = json!(false); - if mutation_was_accepted("distribution-failure", &failure_manifest, &failure_expected) { - accepted.push("confirmed failure survived without cited terminal evidence"); - } - - assert!( - accepted.is_empty(), - "exact key/causality/terminal mutations were accepted: {accepted:?}" - ); -} - -#[test] -fn every_observation_requires_an_explicit_typed_terminal_marker() { - let manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let mut expected = read_json("healthy-package", "expected.json").expect("expected loads"); - expected["transactions"][0]["observations"][0] - .as_object_mut() - .expect("nonterminal observation is an object") - .remove("terminal"); - - assert!( - !mutation_was_accepted("healthy-package", &manifest, &expected), - "nonterminal observation without terminal was accepted" - ); -} - -#[test] -fn coverage_role_and_rotation_states_fail_closed() { - let absent_manifest = read_json("absent-dp", "manifest.json").expect("manifest loads"); - let absent_expected = read_json("absent-dp", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut promoted_coverage = absent_expected.clone(); - promoted_coverage["coverage"][0]["state"] = json!("captured"); - if mutation_was_accepted("absent-dp", &absent_manifest, &promoted_coverage) { - accepted.push("absent source coverage was promoted to captured"); - } - - let mut missing_role = absent_expected.clone(); - missing_role["roleAssessment"]["distributionPointObserved"] = json!(false); - missing_role["roleAssessment"]["roleAbsentInferred"] = json!(true); - if mutation_was_accepted("absent-dp", &absent_manifest, &missing_role) { - accepted.push("missing source path was promoted to missing DP role"); - } - - let mut role_alias_manifest = absent_manifest.clone(); - role_alias_manifest["artifacts"][0]["producerRole"] = json!("distributionPoint"); - if mutation_was_accepted("absent-dp", &role_alias_manifest, &absent_expected) { - accepted.push("basename reclassified the site-server producer as a DP"); - } - - let mut host_alias_manifest = absent_manifest.clone(); - host_alias_manifest["artifacts"][0]["producerHostHandle"] = json!(EXACT_DP); - if mutation_was_accepted("absent-dp", &host_alias_manifest, &absent_expected) { - accepted.push("site-server producer host collapsed onto its DP workflow subject"); - } - - let mut nonphysical_limit_manifest = absent_manifest.clone(); - nonphysical_limit_manifest["artifacts"][0]["collectionLimit"] = - json!({"byteLimit": 4096, "limitApplied": false}); - if mutation_was_accepted("absent-dp", &nonphysical_limit_manifest, &absent_expected) { - accepted.push("absent artifact invented a physical collection-limit policy"); - } - - 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 healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let mut invented_transaction = rotation_expected.clone(); - invented_transaction["transactions"] = healthy_expected["transactions"].clone(); - if mutation_was_accepted( - "rotation-boundary", - &rotation_manifest, - &invented_transaction, - ) { - accepted.push("split rotation fragments formed a transaction"); - } - - assert!( - accepted.is_empty(), - "coverage/role/rotation mutations were accepted: {accepted:?}" - ); -} - -#[test] -fn client_only_and_version_mismatch_controls_stay_independent() { - let client_manifest = - read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); - let client_expected = - read_json("client-only-looking-request", "expected.json").expect("expected loads"); - let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut server_transaction = client_expected.clone(); - server_transaction["transactions"] = healthy_expected["transactions"].clone(); - if mutation_was_accepted( - "client-only-looking-request", - &client_manifest, - &server_transaction, - ) { - accepted.push("client-only record entered the server DP reducer"); - } - - let mismatch_manifest = - read_json("content-version-mismatch", "manifest.json").expect("manifest loads"); - let mismatch_expected = - read_json("content-version-mismatch", "expected.json").expect("expected loads"); - let mut merged_versions = mismatch_expected.clone(); - merged_versions["transactions"] - .as_array_mut() - .expect("transactions are mutable") - .pop(); - if mutation_was_accepted( - "content-version-mismatch", - &mismatch_manifest, - &merged_versions, - ) { - accepted.push("same content across two versions collapsed into one transaction"); - } - - assert!( - accepted.is_empty(), - "client/version separation mutations were accepted: {accepted:?}" - ); -} - -#[test] -fn structured_fixture_fields_are_unique_closed_and_record_local() { - let valid = "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=transfer; Disposition=succeeded; Terminal=false; PackageId=LAB00001; ContentId=content-alpha; ContentVersion=1; SiteCode=LAB; DpHandle=safe:dp:lab-dp-01; ProfileId=dp-server-5.00.test-v1"; - assert!(parse_fixture_fields(valid).is_ok()); - - for invalid in [ - "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=transfer; Phase=validate; Disposition=succeeded; Terminal=false", - "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=transfer; Disposition=succeeded; Terminal=false; Terminal=true", - "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=transfer; Disposition=succeeded; Terminal=false; ServerCause=network", - "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=transfer]LOG]!>; Disposition=succeeded; Terminal=false", - ] { - assert!( - parse_fixture_fields(invalid).is_err(), - "ambiguous or unsupported fields were accepted: {invalid}" - ); - } -} - -#[test] -fn unknown_semantics_collisions_and_output_reordering_fail_closed() { - let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut injected_cause = healthy_expected.clone(); - injected_cause["serverCause"] = json!("The DP triggered the client failure."); - if mutation_was_accepted("healthy-package", &healthy_manifest, &injected_cause) { - accepted.push("unknown server-cause field"); - } - - let mut reversed_observations = healthy_expected.clone(); - reversed_observations["transactions"][0]["observations"] - .as_array_mut() - .expect("observations are mutable") - .reverse(); - if mutation_was_accepted("healthy-package", &healthy_manifest, &reversed_observations) { - accepted.push("reversed observation output"); - } - - let mismatch_manifest = - read_json("content-version-mismatch", "manifest.json").expect("manifest loads"); - let mut mismatch_expected = - read_json("content-version-mismatch", "expected.json").expect("expected loads"); - mismatch_expected["transactions"] - .as_array_mut() - .expect("transactions are mutable") - .reverse(); - if mutation_was_accepted( - "content-version-mismatch", - &mismatch_manifest, - &mismatch_expected, - ) { - accepted.push("reversed transaction output"); - } - - let mut collided_manifest = healthy_manifest.clone(); - collided_manifest["artifacts"][1]["relativePath"] = - collided_manifest["artifacts"][0]["relativePath"].clone(); - collided_manifest["artifacts"][1]["originalBasename"] = - collided_manifest["artifacts"][0]["originalBasename"].clone(); - if mutation_was_accepted("healthy-package", &collided_manifest, &healthy_expected) { - accepted.push("colliding physical evidence destination"); - } - - assert!( - accepted.is_empty(), - "closed-schema/collision/order mutations were accepted: {accepted:?}" - ); -} - -#[test] -fn one_physical_site_log_is_not_duplicated_per_distribution_point_subject() { - let manifest = read_json("content-version-mismatch", "manifest.json").expect("manifest loads"); - let mut physical_sources = BTreeSet::new(); - let mut duplicates = Vec::new(); - - for artifact in manifest["artifacts"] - .as_array() - .expect("artifacts are an array") - { - let identity = ( - artifact["producerHostHandle"] - .as_str() - .expect("producer handle"), - artifact["sanitizedSourcePath"] - .as_str() - .expect("sanitized source path"), - artifact["originalBasename"].as_str().expect("basename"), - artifact["rotation"]["kind"] - .as_str() - .expect("rotation kind"), - ); - if !physical_sources.insert(identity) { - duplicates.push(identity); - } - } - - assert!( - duplicates.is_empty(), - "one physical capture was duplicated to attach multiple workflow subjects: {duplicates:?}" - ); - - let mut falsely_narrowed = manifest.clone(); - falsely_narrowed["artifacts"][0] - .as_object_mut() - .expect("artifact is an object") - .remove("workflowSubjectBasis"); - falsely_narrowed["artifacts"][0]["workflowSubjectHandle"] = json!(EXACT_DP); - assert!( - validate_manifest( - &corpus_root().join("content-version-mismatch"), - &falsely_narrowed - ) - .is_err(), - "a shared physical site log was falsely narrowed to one DP despite containing another" - ); -} - -#[test] -fn source_local_classifications_are_bound_to_physical_coverage_semantics() { - let client_manifest = - read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); - let client_expected = - read_json("client-only-looking-request", "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 client_as_malformed = client_expected.clone(); - client_as_malformed["sourceLocalObservations"][0]["classification"] = - json!("malformedEvidence"); - if mutation_was_accepted( - "client-only-looking-request", - &client_manifest, - &client_as_malformed, - ) { - accepted.push("captured client evidence relabeled as malformed server evidence"); - } - - let mut split_as_client = rotation_expected.clone(); - split_as_client["sourceLocalObservations"][0]["classification"] = - json!("ignoredClientEvidence"); - if mutation_was_accepted("rotation-boundary", &rotation_manifest, &split_as_client) { - accepted.push("split server rotation relabeled as ignored client evidence"); - } - - let mut malformed_as_split = rotation_expected.clone(); - malformed_as_split["sourceLocalObservations"][1]["classification"] = json!("rotationSplit"); - if mutation_was_accepted("rotation-boundary", &rotation_manifest, &malformed_as_split) { - accepted.push("parse-failed source relabeled as a rotation split"); - } - - assert!( - accepted.is_empty(), - "source-local classifications were detached from physical coverage: {accepted:?}" - ); -} - -#[test] -fn path_provenance_aliases_fail_closed() { - let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut duplicate_fingerprint = healthy_manifest.clone(); - duplicate_fingerprint["artifacts"][1]["pathFingerprint"] = - duplicate_fingerprint["artifacts"][0]["pathFingerprint"].clone(); - if mutation_was_accepted("healthy-package", &duplicate_fingerprint, &healthy_expected) { - accepted.push("duplicate path fingerprint"); - } - - let mut dot_segment_alias = healthy_manifest.clone(); - dot_segment_alias["artifacts"][1]["relativePath"] = - json!("evidence/server-dp-distribution/site/current/./PkgXferMgr.log"); - if mutation_was_accepted("healthy-package", &dot_segment_alias, &healthy_expected) { - accepted.push("dot-segment physical evidence alias"); - } - - let mut unsafe_source_path = healthy_manifest.clone(); - unsafe_source_path["artifacts"][2]["sanitizedSourcePath"] = - json!("SYNTHETIC://../../Users/RealUser/SMSDPProv.log"); - if mutation_was_accepted("healthy-package", &unsafe_source_path, &healthy_expected) { - accepted.push("unsafe sanitized source path"); - } - - assert!( - accepted.is_empty(), - "unsafe or colliding path provenance was accepted: {accepted:?}" - ); -} - -#[test] -fn exact_profile_requires_the_pinned_synthetic_source_version() { - let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut missing_version = healthy_manifest.clone(); - missing_version["artifacts"][0] - .as_object_mut() - .expect("artifact is an object") - .remove("sourceVersion"); - if mutation_was_accepted("healthy-package", &missing_version, &healthy_expected) { - accepted.push("missing source version retained Exact"); - } - - for (label, version) in [ - ("unknown source version retained Exact", "5.00.TEST.UNKNOWN"), - ("malformed source version retained Exact", "5.00.TEST."), - ( - "prefix-collision source version retained Exact", - "5.00.TEST.0001-extra", - ), - ] { - let mut mutated = healthy_manifest.clone(); - mutated["artifacts"][0]["sourceVersion"] = json!(version); - if mutation_was_accepted("healthy-package", &mutated, &healthy_expected) { - accepted.push(label); - } - } - - assert!( - accepted.is_empty(), - "unvalidated source versions selected the Exact profile: {accepted:?}" - ); -} - -#[test] -fn topology_roles_are_typed_and_known() { - let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut non_string_role = healthy_manifest.clone(); - non_string_role["topology"]["rolesObserved"] - .as_array_mut() - .expect("roles are an array") - .push(json!(7)); - if mutation_was_accepted("healthy-package", &non_string_role, &healthy_expected) { - accepted.push("non-string observed role"); - } - - let mut unknown_role = healthy_manifest.clone(); - unknown_role["topology"]["rolesObserved"] - .as_array_mut() - .expect("roles are an array") - .push(json!("unknownRole")); - if mutation_was_accepted("healthy-package", &unknown_role, &healthy_expected) { - accepted.push("unknown observed role"); - } - - assert!( - accepted.is_empty(), - "malformed role topology was accepted: {accepted:?}" - ); -} - -#[test] -fn rotation_shapes_match_the_shared_canonical_contract() { - let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let healthy_expected = read_json("healthy-package", "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 current_with_value = healthy_manifest.clone(); - current_with_value["artifacts"][0]["rotation"]["value"] = json!("unexpected"); - if mutation_was_accepted("healthy-package", ¤t_with_value, &healthy_expected) { - accepted.push("current rotation with value"); - } - - let mut lo_with_value = rotation_manifest.clone(); - lo_with_value["artifacts"][1]["rotation"]["value"] = json!("unexpected"); - if mutation_was_accepted("rotation-boundary", &lo_with_value, &rotation_expected) { - accepted.push("lo_ rotation with value"); - } - - let mut numbered_zero = healthy_manifest.clone(); - numbered_zero["artifacts"][0]["rotation"]["kind"] = json!("numbered"); - numbered_zero["artifacts"][0]["rotation"]["value"] = json!(0); - if mutation_was_accepted("healthy-package", &numbered_zero, &healthy_expected) { - accepted.push("numbered rotation with zero value"); - } - - let mut malformed_timestamp = healthy_manifest.clone(); - malformed_timestamp["artifacts"][0]["rotation"]["kind"] = json!("timestamped"); - malformed_timestamp["artifacts"][0]["rotation"]["value"] = json!("20260730_122000"); - if mutation_was_accepted("healthy-package", &malformed_timestamp, &healthy_expected) { - accepted.push("timestamped rotation with noncanonical value"); - } - - assert!( - accepted.is_empty(), - "noncanonical rotation shapes were accepted: {accepted:?}" - ); -} - -#[test] -fn transaction_observation_ids_and_evidence_are_single_use() { - let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut duplicate_observation_id = healthy_expected.clone(); - duplicate_observation_id["transactions"][0]["observations"][1]["observationId"] = - json!("01-receive"); - if mutation_was_accepted( - "healthy-package", - &healthy_manifest, - &duplicate_observation_id, - ) { - accepted.push("duplicate observation ID"); - } - - let mut reused_evidence = healthy_expected.clone(); - let mut repeated = reused_evidence["transactions"][0]["observations"][5].clone(); - repeated["observationId"] = json!("07-report-copy"); - reused_evidence["transactions"][0]["observations"] - .as_array_mut() - .expect("observations are an array") - .push(repeated); - if mutation_was_accepted("healthy-package", &healthy_manifest, &reused_evidence) { - accepted.push("one physical evidence reference consumed twice"); - } - - assert!( - accepted.is_empty(), - "duplicate observations or reused evidence were accepted: {accepted:?}" - ); -} - -#[test] -fn physical_path_provenance_is_case_folded_bounded_and_basename_bound() { - let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut case_folded_fingerprint = healthy_manifest.clone(); - case_folded_fingerprint["artifacts"][1]["pathFingerprint"] = json!("synthetic:HEALTHY-DISTMGR"); - if mutation_was_accepted( - "healthy-package", - &case_folded_fingerprint, - &healthy_expected, - ) { - accepted.push("case-folded duplicate path fingerprint"); - } - - let mut basename_detached = healthy_manifest.clone(); - basename_detached["artifacts"][1]["sanitizedSourcePath"] = - basename_detached["artifacts"][0]["sanitizedSourcePath"].clone(); - if mutation_was_accepted("healthy-package", &basename_detached, &healthy_expected) { - accepted.push("sanitized source path detached from its original basename"); - } - - let mut identity_bearing_path = healthy_manifest.clone(); - identity_bearing_path["artifacts"][2]["sanitizedSourcePath"] = - json!("SYNTHETIC://Users/RealUser/SMSDPProv.log"); - if mutation_was_accepted("healthy-package", &identity_bearing_path, &healthy_expected) { - accepted.push("identity-bearing sanitized source root"); - } - - assert!( - accepted.is_empty(), - "unbounded or colliding physical path provenance was accepted: {accepted:?}" - ); -} - -#[test] -fn distribution_point_handles_are_typed_known_unique_and_complete() { - let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut non_string_handle = healthy_manifest.clone(); - non_string_handle["topology"]["distributionPointHandles"] = json!([EXACT_DP, 7]); - if mutation_was_accepted("healthy-package", &non_string_handle, &healthy_expected) { - accepted.push("non-string distribution-point topology handle"); - } - - let mut unknown_handle = healthy_manifest.clone(); - unknown_handle["topology"]["distributionPointHandles"] = json!([EXACT_DP, "safe:dp:lab-dp-99"]); - if mutation_was_accepted("healthy-package", &unknown_handle, &healthy_expected) { - accepted.push("unknown distribution-point topology handle"); - } - - let mut duplicate_handle = healthy_manifest.clone(); - duplicate_handle["topology"]["distributionPointHandles"] = json!([EXACT_DP, EXACT_DP]); - if mutation_was_accepted("healthy-package", &duplicate_handle, &healthy_expected) { - accepted.push("duplicate distribution-point topology handle"); - } - - let mut missing_primary = healthy_manifest.clone(); - missing_primary["topology"]["distributionPointHandles"] = json!(["safe:dp:lab-dp-02"]); - if mutation_was_accepted("healthy-package", &missing_primary, &healthy_expected) { - accepted.push("distribution-point topology omitted its primary handle"); - } - - let mut missing_handle_array = healthy_manifest.clone(); - missing_handle_array["topology"] - .as_object_mut() - .expect("topology is an object") - .remove("distributionPointHandles"); - if mutation_was_accepted("healthy-package", &missing_handle_array, &healthy_expected) { - accepted.push("distribution-point topology omitted its exact handle array"); - } - - assert!( - accepted.is_empty(), - "malformed distribution-point topology handles were accepted: {accepted:?}" - ); -} - -#[test] -fn physical_rotation_provenance_is_typed_and_complete() { - let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut non_boolean_fragment = healthy_manifest.clone(); - non_boolean_fragment["artifacts"][0]["rotation"]["fragmentComplete"] = json!("true"); - if mutation_was_accepted("healthy-package", &non_boolean_fragment, &healthy_expected) { - accepted.push("non-boolean physical fragment completeness"); - } - - let mut non_string_lineage = healthy_manifest.clone(); - non_string_lineage["artifacts"][0]["rotation"]["lineageId"] = json!(7); - if mutation_was_accepted("healthy-package", &non_string_lineage, &healthy_expected) { - accepted.push("non-string rotation lineage"); - } - - let mut missing_lineage = healthy_manifest.clone(); - missing_lineage["artifacts"][0]["rotation"] - .as_object_mut() - .expect("rotation is an object") - .remove("lineageId"); - if mutation_was_accepted("healthy-package", &missing_lineage, &healthy_expected) { - accepted.push("missing rotation lineage"); - } - - let mut missing_fragment = healthy_manifest.clone(); - missing_fragment["artifacts"][0]["rotation"] - .as_object_mut() - .expect("rotation is an object") - .remove("fragmentComplete"); - if mutation_was_accepted("healthy-package", &missing_fragment, &healthy_expected) { - accepted.push("missing physical fragment completeness"); - } - - assert_eq!( - rotation_from_manifest(&json!({"kind": "numbered", "value": 3})) - .expect("canonical numbered rotation"), - SccmRotation::Numbered(3) - ); - assert_eq!( - rotation_from_manifest(&json!({"kind": "timestamped", "value": "20260730-150000"})) - .expect("canonical timestamped rotation"), - SccmRotation::Timestamped("20260730-150000".to_owned()) - ); - - assert!( - accepted.is_empty(), - "malformed or incomplete rotation provenance was accepted: {accepted:?}" - ); -} - -#[test] -fn observation_ids_and_physical_evidence_are_unique_across_classes() { - let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let healthy_expected = read_json("healthy-package", "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 client_manifest = - read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); - let client_expected = - read_json("client-only-looking-request", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut empty_transaction_observation = healthy_expected.clone(); - empty_transaction_observation["transactions"][0]["observations"][0]["observationId"] = - json!(""); - if mutation_was_accepted( - "healthy-package", - &healthy_manifest, - &empty_transaction_observation, - ) { - accepted.push("empty transaction observation ID"); - } - - let mut duplicate_source_local_id = rotation_expected.clone(); - duplicate_source_local_id["sourceLocalObservations"][1]["observationId"] = - duplicate_source_local_id["sourceLocalObservations"][0]["observationId"].clone(); - if mutation_was_accepted( - "rotation-boundary", - &rotation_manifest, - &duplicate_source_local_id, - ) { - accepted.push("duplicate source-local observation ID"); - } - - let mut reused_source_local_evidence = client_expected.clone(); - let repeated = - reused_source_local_evidence["sourceLocalObservations"][0]["evidence"][0].clone(); - reused_source_local_evidence["sourceLocalObservations"][0]["evidence"] - .as_array_mut() - .expect("source-local evidence is an array") - .push(repeated); - if mutation_was_accepted( - "client-only-looking-request", - &client_manifest, - &reused_source_local_evidence, - ) { - accepted.push("source-local physical evidence consumed twice"); - } - - assert!( - accepted.is_empty(), - "observation identity or evidence single-use violations were accepted: {accepted:?}" - ); -} - -#[test] -fn source_local_artifact_ids_are_strict_strings_across_classifications() { - let client_manifest = - read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); - let client_expected = - read_json("client-only-looking-request", "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 surfaces = [ - ( - "ignoredClientEvidence", - "client-only-looking-request", - &client_manifest, - &client_expected, - 0usize, - ), - ( - "rotationSplit", - "rotation-boundary", - &rotation_manifest, - &rotation_expected, - 0usize, - ), - ( - "malformedEvidence", - "rotation-boundary", - &rotation_manifest, - &rotation_expected, - 1usize, - ), - ]; - let invalid_entries = [ - ("numeric", json!(7)), - ("null", Value::Null), - ("boolean", json!(true)), - ("object", json!({"unexpected": "value"})), - ("empty string", json!("")), - ]; - let mut accepted = Vec::new(); - - for (surface, scenario, manifest, expected, observation_index) in surfaces { - for (shape, invalid_entry) in &invalid_entries { - let mut mutated = expected.clone(); - mutated["sourceLocalObservations"][observation_index]["artifactIds"] - .as_array_mut() - .expect("source-local artifact IDs are an array") - .push(invalid_entry.clone()); - if mutation_was_accepted(scenario, manifest, &mutated) { - accepted.push(format!("{surface} accepted appended {shape} artifact ID")); - } - } - - let mut mixed_array = expected.clone(); - mixed_array["sourceLocalObservations"][observation_index]["artifactIds"] - .as_array_mut() - .expect("source-local artifact IDs are an array") - .extend([ - json!(7), - Value::Null, - json!(true), - json!({"unexpected": "value"}), - ]); - if mutation_was_accepted(scenario, manifest, &mixed_array) { - accepted.push(format!("{surface} accepted a mixed-type artifact ID array")); - } - } - - assert!( - accepted.is_empty(), - "malformed source-local physical artifact IDs were accepted: {accepted:?}" - ); -} - -#[test] -fn source_local_evidence_is_typed_nonempty_closed_and_physical() { - let client_manifest = - read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); - let client_expected = - read_json("client-only-looking-request", "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 surfaces = [ - ( - "ignoredClientEvidence", - "client-only-looking-request", - &client_manifest, - &client_expected, - 0usize, - json!([{ - "artifactId": "dp-client-control-01-data-transfer", - "startLine": 1, - "endLine": 1 - }]), - ), - ( - "rotationSplit", - "rotation-boundary", - &rotation_manifest, - &rotation_expected, - 0usize, - json!([ - { - "artifactId": "dp-rotation-01-current-fragment", - "startLine": 1, - "endLine": 1 - }, - { - "artifactId": "dp-rotation-02-lo-fragment", - "startLine": 1, - "endLine": 1 - } - ]), - ), - ( - "malformedEvidence", - "rotation-boundary", - &rotation_manifest, - &rotation_expected, - 1usize, - json!([{ - "artifactId": "dp-rotation-03-malformed", - "startLine": 1, - "endLine": 1 - }]), - ), - ]; - let non_array_shapes = [ - ("null", Value::Null), - ("boolean", json!(true)), - ("numeric", json!(7)), - ("string", json!("not-an-evidence-array")), - ("object", json!({"unexpected": "value"})), - ]; - let mut accepted = Vec::new(); - - for (surface, scenario, manifest, expected, observation_index, valid_evidence) in surfaces { - for (shape, invalid_evidence) in &non_array_shapes { - let mut mutated = expected.clone(); - mutated["sourceLocalObservations"][observation_index]["evidence"] = - invalid_evidence.clone(); - if mutation_was_accepted(scenario, manifest, &mutated) { - accepted.push(format!("{surface} accepted {shape} evidence")); - } - } - - let mut empty = expected.clone(); - empty["sourceLocalObservations"][observation_index]["evidence"] = json!([]); - if mutation_was_accepted(scenario, manifest, &empty) { - accepted.push(format!("{surface} accepted an empty evidence array")); - } - - let mut mixed = expected.clone(); - let mut mixed_entries = valid_evidence - .as_array() - .expect("valid source-local evidence is an array") - .clone(); - mixed_entries.extend([ - Value::Null, - json!(true), - json!(7), - json!("not-an-evidence-reference"), - json!({"unexpected": "value"}), - ]); - mixed["sourceLocalObservations"][observation_index]["evidence"] = - Value::Array(mixed_entries); - if mutation_was_accepted(scenario, manifest, &mixed) { - accepted.push(format!("{surface} accepted a mixed evidence array")); - } - - let mut open_reference = expected.clone(); - open_reference["sourceLocalObservations"][observation_index]["evidence"] = - valid_evidence.clone(); - open_reference["sourceLocalObservations"][observation_index]["evidence"][0]["unexpected"] = - json!("value"); - if mutation_was_accepted(scenario, manifest, &open_reference) { - accepted.push(format!( - "{surface} accepted an open evidence-reference object" - )); - } - - let mut missing_line = expected.clone(); - missing_line["sourceLocalObservations"][observation_index]["evidence"] = - valid_evidence.clone(); - missing_line["sourceLocalObservations"][observation_index]["evidence"][0] - .as_object_mut() - .expect("evidence reference is an object") - .remove("endLine"); - if mutation_was_accepted(scenario, manifest, &missing_line) { - accepted.push(format!( - "{surface} accepted an incomplete evidence reference" - )); - } - - let mut unbound_line = expected.clone(); - unbound_line["sourceLocalObservations"][observation_index]["evidence"] = - valid_evidence.clone(); - unbound_line["sourceLocalObservations"][observation_index]["evidence"][0]["startLine"] = - json!(99); - unbound_line["sourceLocalObservations"][observation_index]["evidence"][0]["endLine"] = - json!(99); - if mutation_was_accepted(scenario, manifest, &unbound_line) { - accepted.push(format!("{surface} accepted an unbound physical line")); - } - } - - assert!( - accepted.is_empty(), - "malformed source-local evidence was accepted: {accepted:?}" - ); -} - -#[test] -fn coverage_gap_ids_are_typed_nonempty_unique_and_physical() { - let incomplete_manifest = read_json("incomplete", "manifest.json").expect("manifest loads"); - let incomplete_expected = read_json("incomplete", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut non_string_gap = incomplete_expected.clone(); - non_string_gap["transactions"][0]["coverageGapArtifactIds"] - .as_array_mut() - .expect("coverage gaps are an array") - .push(json!(7)); - if mutation_was_accepted("incomplete", &incomplete_manifest, &non_string_gap) { - accepted.push("non-string coverage-gap artifact ID"); - } - - let mut empty_gap = incomplete_expected.clone(); - empty_gap["transactions"][0]["coverageGapArtifactIds"][0] = json!(""); - if mutation_was_accepted("incomplete", &incomplete_manifest, &empty_gap) { - accepted.push("empty coverage-gap artifact ID"); - } - - let mut duplicate_gap = incomplete_expected.clone(); - let repeated = duplicate_gap["transactions"][0]["coverageGapArtifactIds"][1].clone(); - duplicate_gap["transactions"][0]["coverageGapArtifactIds"] - .as_array_mut() - .expect("coverage gaps are an array") - .push(repeated); - if mutation_was_accepted("incomplete", &incomplete_manifest, &duplicate_gap) { - accepted.push("duplicate coverage-gap artifact ID"); - } - - assert!( - accepted.is_empty(), - "malformed coverage-gap artifact IDs were accepted: {accepted:?}" - ); -} - -#[test] -fn capped_transaction_evidence_cannot_retain_high_confidence_terminal_health() { - let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let healthy_expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - for (label, artifact_index) in [("earlier phase", 0usize), ("terminal phase", 2usize)] { - let mut manifest = healthy_manifest.clone(); - let mut expected = healthy_expected.clone(); - let artifact_id = manifest["artifacts"][artifact_index]["artifactId"].clone(); - let bytes_copied = manifest["artifacts"][artifact_index]["bytesCopied"].clone(); - manifest["artifacts"][artifact_index]["captureState"] = json!("capped"); - manifest["artifacts"][artifact_index]["collectionLimit"] = - json!({"byteLimit": bytes_copied, "limitApplied": true}); - expected["coverage"][artifact_index]["state"] = json!("capped"); - expected["transactions"][0]["coverageGapArtifactIds"] = json!([artifact_id]); - expected["artifactRequests"] = json!([{ - "sourceId": "server-dp-distribution", - "reasonCode": "coverageCapped" - }]); - - if mutation_was_accepted("healthy-package", &manifest, &expected) { - accepted.push(label); - } - } - - assert!( - accepted.is_empty(), - "capped transaction evidence retained high-confidence success: {accepted:?}" - ); -} - -#[test] -fn terminal_success_is_bound_to_the_cited_serve_or_report_record() { - let temporary = temporary_scenario("healthy-package"); - let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let mut expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let relative_path = manifest["artifacts"][2]["relativePath"] - .as_str() - .expect("provider artifact has a path") - .to_owned(); - - replace_fixture_text( - &temporary.root, - &relative_path, - "Phase=validate; Disposition=succeeded; Terminal=false;", - "Phase=validate; Disposition=succeeded; Terminal=true;", - ); - replace_fixture_text( - &temporary.root, - &relative_path, - "Phase=serveOrReport; Disposition=succeeded; Terminal=true;", - "Phase=serveOrReport; Disposition=succeeded; Terminal=false;", - ); - refresh_artifact_bytes(&mut manifest, 2, &temporary.root); - expected["transactions"][0]["observations"][3]["terminal"] = json!(true); - expected["transactions"][0]["observations"][5]["terminal"] = json!(false); - - assert!( - !mutation_at_root_was_accepted("healthy-package", &temporary.root, &manifest, &expected,), - "an earlier terminal success survived later nonterminal ServeOrReport evidence" - ); -} - -#[test] -fn later_same_key_retry_invalidates_stale_high_success() { - let temporary = temporary_scenario("healthy-package"); - let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let mut expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let relative_path = manifest["artifacts"][2]["relativePath"] - .as_str() - .expect("provider artifact has a path") - .to_owned(); - - append_fixture_record( - &temporary.root, - &relative_path, - r#""#, - ); - refresh_artifact_bytes(&mut manifest, 2, &temporary.root); - expected["transactions"][0]["observations"] - .as_array_mut() - .expect("observations are an array") - .push(json!({ - "observationId": "07-report-retry", - "phase": "serveOrReport", - "disposition": "retrying", - "terminal": false, - "evidence": [{"artifactId": "dp-healthy-03-provider", "startLine": 4, "endLine": 4}] - })); - - assert!( - !mutation_at_root_was_accepted("healthy-package", &temporary.root, &manifest, &expected,), - "a later same-key retry retained stale high-confidence success" - ); -} - -#[test] -fn later_same_key_success_invalidates_stale_deferred_outcome() { - let temporary = temporary_scenario("transfer-retry"); - let mut manifest = read_json("transfer-retry", "manifest.json").expect("manifest loads"); - let mut expected = read_json("transfer-retry", "expected.json").expect("expected loads"); - let relative_path = manifest["artifacts"][1]["relativePath"] - .as_str() - .expect("transfer artifact has a path") - .to_owned(); - - append_fixture_record( - &temporary.root, - &relative_path, - r#""#, - ); - refresh_artifact_bytes(&mut manifest, 1, &temporary.root); - expected["transactions"][0]["lastSuccessfulPhase"] = json!("transfer"); - expected["transactions"][0]["observations"] - .as_array_mut() - .expect("observations are an array") - .push(json!({ - "observationId": "04-transfer-succeeded", - "phase": "transfer", - "disposition": "succeeded", - "terminal": false, - "evidence": [{"artifactId": "dp-transfer-retry-02-pkgxfer", "startLine": 2, "endLine": 2}] - })); - - assert!( - !mutation_at_root_was_accepted("transfer-retry", &temporary.root, &manifest, &expected,), - "a later same-key success retained stale deferred classification" - ); -} - -#[test] -fn equal_utc_same_artifact_retry_cannot_be_reordered_before_terminal_success() { - let temporary = temporary_scenario("healthy-package"); - let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let mut expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let relative_path = manifest["artifacts"][2]["relativePath"] - .as_str() - .expect("provider artifact has a path") - .to_owned(); - - append_fixture_record( - &temporary.root, - &relative_path, - r#""#, - ); - refresh_artifact_bytes(&mut manifest, 2, &temporary.root); - expected["transactions"][0]["observations"] - .as_array_mut() - .expect("observations are an array") - .insert( - 5, - json!({ - "observationId": "05-report-retry", - "phase": "serveOrReport", - "disposition": "retrying", - "terminal": false, - "evidence": [{ - "artifactId": "dp-healthy-03-provider", - "startLine": 4, - "endLine": 4 - }] - }), - ); - - assert!( - !mutation_at_root_was_accepted("healthy-package", &temporary.root, &manifest, &expected), - "an observation ID reordered a later equal-UTC physical retry before stale high success" - ); -} - -#[test] -fn equal_utc_same_artifact_recovery_cannot_be_reordered_before_terminal_failure() { - let temporary = temporary_scenario("validation-failure"); - let mut manifest = read_json("validation-failure", "manifest.json").expect("manifest loads"); - let mut expected = read_json("validation-failure", "expected.json").expect("expected loads"); - let relative_path = manifest["artifacts"][2]["relativePath"] - .as_str() - .expect("provider artifact has a path") - .to_owned(); - - append_fixture_record( - &temporary.root, - &relative_path, - r#""#, - ); - refresh_artifact_bytes(&mut manifest, 2, &temporary.root); - expected["transactions"][0]["lastSuccessfulPhase"] = json!("validate"); - expected["transactions"][0]["observations"] - .as_array_mut() - .expect("observations are an array") - .insert( - 3, - json!({ - "observationId": "03-validate-recovered", - "phase": "validate", - "disposition": "succeeded", - "terminal": false, - "evidence": [{ - "artifactId": "dp-validation-failure-03-provider", - "startLine": 2, - "endLine": 2 - }] - }), - ); - - assert!( - !mutation_at_root_was_accepted("validation-failure", &temporary.root, &manifest, &expected,), - "an observation ID reordered a later equal-UTC physical recovery before stale high failure" - ); -} - -#[test] -fn equal_utc_same_artifact_forward_line_order_remains_usable() { - let temporary = temporary_scenario("healthy-package"); - let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let relative_path = manifest["artifacts"][2]["relativePath"] - .as_str() - .expect("provider artifact has a path") - .to_owned(); - - replace_fixture_text( - &temporary.root, - &relative_path, - r#""#, - ); - refresh_artifact_bytes(&mut manifest, 2, &temporary.root); - expected["transactions"][0]["observations"] - .as_array_mut() - .expect("observations are an array") - .insert( - 5, - json!({ - "observationId": "05-serve-retry", - "phase": "serveOrReport", - "disposition": "retrying", - "terminal": false, - "evidence": [{ - "artifactId": "dp-serve-03-provider", - "startLine": 3, - "endLine": 3 - }] - }), - ); - - assert!( - !mutation_at_root_was_accepted("serve-observed", &temporary.root, &manifest, &expected), - "different artifacts at equal UTC used observation IDs to retain stale high success" - ); -} - -#[test] -fn equal_utc_cross_rotation_outcomes_fail_closed_as_ambiguous() { - let temporary = temporary_scenario("healthy-package"); - let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let mut expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let current_relative_path = manifest["artifacts"][2]["relativePath"] - .as_str() - .expect("provider artifact has a path") - .to_owned(); - let rollback_relative_path = "evidence/server-dp-distribution/dp/lo_/SMSDPProv.log".to_owned(); - let rollback_fixture_path = temporary.root.join(&rollback_relative_path); - std::fs::create_dir_all( - rollback_fixture_path - .parent() - .expect("rollback fixture has a parent"), - ) - .expect("rollback fixture parent is created"); - std::fs::copy( - temporary.root.join(¤t_relative_path), - &rollback_fixture_path, - ) - .expect("current provider evidence is copied to the rollback artifact"); - manifest["artifacts"][2]["sanitizedSourcePath"] = - json!("SYNTHETIC://dp-root/Logs/SMSDPProv.lo_"); - manifest["artifacts"][2]["rotation"]["kind"] = json!("lo_"); - manifest["artifacts"][2]["relativePath"] = json!(rollback_relative_path); - refresh_artifact_bytes(&mut manifest, 2, &temporary.root); - - let current_record = r#""#; - std::fs::write( - temporary.root.join(¤t_relative_path), - format!("{current_record}\n"), - ) - .expect("current rotation retry fixture is written"); - let current_bytes = std::fs::metadata(temporary.root.join(¤t_relative_path)) - .expect("current rotation retry fixture is readable") - .len(); - manifest["artifacts"] - .as_array_mut() - .expect("artifacts are an array") - .push(json!({ - "artifactId": "dp-healthy-04-provider-current", - "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": "healthy-provider", - "fragmentComplete": true - }, - "captureState": "captured", - "sourceVersion": "5.00.TEST.0001", - "collectedUtc": "2026-07-30T12:10:00Z", - "encoding": "utf-8", - "collectionLimit": { - "byteLimit": 4096, - "limitApplied": false - }, - "bytesCopied": current_bytes, - "relativePath": current_relative_path - })); - expected["coverage"] - .as_array_mut() - .expect("coverage is an array") - .push(json!({ - "artifactId": "dp-healthy-04-provider-current", - "state": "captured" - })); - expected["transactions"][0]["observations"] - .as_array_mut() - .expect("observations are an array") - .insert( - 5, - json!({ - "observationId": "05-report-retry", - "phase": "serveOrReport", - "disposition": "retrying", - "terminal": false, - "evidence": [{ - "artifactId": "dp-healthy-04-provider-current", - "startLine": 1, - "endLine": 1 - }] - }), - ); - - assert!( - !mutation_at_root_was_accepted("healthy-package", &temporary.root, &manifest, &expected), - "equal-UTC current/rollback outcomes used observation IDs to retain stale high success" - ); -} - -#[test] -fn correlation_eligible_incomplete_output_requires_evidence_gaps_and_requests() { - 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 mut uncited_key = expected.clone(); - uncited_key["transactions"][0]["observations"] = json!([]); - uncited_key["transactions"][0]["lastSuccessfulPhase"] = Value::Null; - if mutation_was_accepted("incomplete", &manifest, &uncited_key) { - accepted.push("correlation-eligible exact key with zero cited logical records"); - } - - let mut no_gaps = expected.clone(); - no_gaps["transactions"][0]["coverageGapArtifactIds"] = json!([]); - if mutation_was_accepted("incomplete", &manifest, &no_gaps) { - accepted.push("insufficientEvidence transaction with no physical gaps"); - } - - let mut no_requests = expected.clone(); - no_requests["artifactRequests"] = json!([]); - if mutation_was_accepted("incomplete", &manifest, &no_requests) { - accepted.push("insufficientEvidence transaction with no bounded request"); - } - - assert!( - accepted.is_empty(), - "incomplete output escaped evidence-first coverage requirements: {accepted:?}" - ); -} - -#[test] -fn identity_bearing_fixture_fields_are_bounded_role_local_and_not_public() { - let client_expected = - read_json("client-only-looking-request", "expected.json").expect("expected loads"); - let public_json = serde_json::to_string(&client_expected).expect("public output serializes"); - for forbidden in ["ClientHandle", "RequestId", "RealUser", "RealRequest"] { - assert!( - !public_json.contains(forbidden), - "public expected JSON exposes raw identity marker {forbidden}" - ); - } - - let mut accepted = Vec::new(); - for (label, original, replacement) in [ - ( - "unbounded client handle", - "ClientHandle=safe:client:lab-client-01", - "ClientHandle=RealUser", - ), - ( - "unbounded request ID", - "RequestId=client-request-01", - "RequestId=RealRequest", - ), - ] { - let temporary = temporary_scenario("client-only-looking-request"); - let mut manifest = - read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); - let relative_path = manifest["artifacts"][0]["relativePath"] - .as_str() - .expect("client artifact has a path") - .to_owned(); - replace_fixture_text(&temporary.root, &relative_path, original, replacement); - refresh_artifact_bytes(&mut manifest, 0, &temporary.root); - if mutation_at_root_was_accepted( - "client-only-looking-request", - &temporary.root, - &manifest, - &client_expected, - ) { - accepted.push(label); - } - } - - let temporary = temporary_scenario("healthy-package"); - let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let relative_path = manifest["artifacts"][2]["relativePath"] - .as_str() - .expect("provider artifact has a path") - .to_owned(); - replace_fixture_text( - &temporary.root, - &relative_path, - "ProfileId=dp-server-5.00.test-v1]LOG]!>\n", - ); - std::fs::write(&fixture_path, contents).expect("later recovery evidence is written"); - refresh_artifact_bytes(&mut manifest, 2, &temporary.root); - expected["transactions"][0]["observations"] - .as_array_mut() - .expect("observations are an array") - .push(json!({ - "observationId": "05-available", - "phase": "makeAvailable", - "disposition": "succeeded", - "terminal": false, - "evidence": [{ - "artifactId": "dp-validation-failure-03-provider", - "startLine": 2, - "endLine": 2 - }] - })); - expected["transactions"][0]["lastSuccessfulPhase"] = json!("makeAvailable"); - - assert!( - !mutation_at_root_was_accepted("validation-failure", &temporary.root, &manifest, &expected,), - "a later same-key success retained a stale high confirmed failure" - ); -} - -#[test] -fn uncited_later_same_key_terminal_failure_invalidates_high_success() { - let temporary = temporary_scenario("healthy-package"); - let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let relative_path = manifest["artifacts"][2]["relativePath"] - .as_str() - .expect("provider artifact has a path") - .to_owned(); - let fixture_path = temporary.root.join(&relative_path); - let mut contents = - std::fs::read_to_string(&fixture_path).expect("provider fixture is readable"); - contents.push_str( - "\n", - ); - std::fs::write(&fixture_path, contents).expect("later terminal failure is written"); - refresh_artifact_bytes(&mut manifest, 2, &temporary.root); - - assert!( - !mutation_at_root_was_accepted("healthy-package", &temporary.root, &manifest, &expected,), - "an uncited later same-key terminal failure retained stale high success" - ); -} - -#[test] -fn admitted_distribution_point_producers_require_the_observed_role() { - let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let mut expected = read_json("healthy-package", "expected.json").expect("expected loads"); - manifest["topology"]["rolesObserved"] = json!(["siteServer"]); - expected["roleAssessment"]["distributionPointObserved"] = json!(false); - - assert!( - !mutation_was_accepted("healthy-package", &manifest, &expected), - "DP-produced evidence retained high success while the DP role was not observed" - ); -} - -#[test] -fn distribution_point_observed_is_a_required_boolean() { - let manifest = - read_json("client-only-looking-request", "manifest.json").expect("manifest loads"); - let expected = - read_json("client-only-looking-request", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut missing = expected.clone(); - missing["roleAssessment"] - .as_object_mut() - .expect("role assessment is an object") - .remove("distributionPointObserved"); - if mutation_was_accepted("client-only-looking-request", &manifest, &missing) { - accepted.push("missing"); - } - - for (shape, value) in [ - ("null", Value::Null), - ("string", json!("false")), - ("number", json!(0)), - ] { - let mut malformed = expected.clone(); - malformed["roleAssessment"]["distributionPointObserved"] = value; - if mutation_was_accepted("client-only-looking-request", &manifest, &malformed) { - accepted.push(shape); - } - } - - assert!( - accepted.is_empty(), - "distributionPointObserved accepted non-Boolean shapes: {accepted:?}" - ); -} - -#[test] -fn incomplete_transaction_gaps_are_bound_to_the_exact_distribution_point() { - let mut manifest = read_json("incomplete", "manifest.json").expect("manifest loads"); - let expected = read_json("incomplete", "expected.json").expect("expected loads"); - manifest["topology"]["distributionPointHandles"] = json!([EXACT_DP, EXACT_DP_02]); - manifest["artifacts"][1]["workflowSubjectHandle"] = json!(EXACT_DP_02); - manifest["artifacts"][2]["producerHostHandle"] = json!(EXACT_DP_02); - manifest["artifacts"][2]["workflowSubjectHandle"] = json!(EXACT_DP_02); - manifest["artifacts"][2]["sanitizedSourcePath"] = - json!("SYNTHETIC://dp-02-root/Logs/SMSDPProv.log"); - - assert!( - !mutation_was_accepted("incomplete", &manifest, &expected), - "DP-02 gaps and requests satisfied an exact DP-01 transaction" - ); -} - -#[test] -fn rotation_split_requires_one_physical_log_family() { - let temporary = temporary_scenario("rotation-boundary"); - let mut manifest = read_json("rotation-boundary", "manifest.json").expect("manifest loads"); - let expected = read_json("rotation-boundary", "expected.json").expect("expected loads"); - let old_relative_path = manifest["artifacts"][1]["relativePath"] - .as_str() - .expect("lo_ artifact has a path") - .to_owned(); - let new_relative_path = "evidence/server-dp-distribution/site/lo_/PkgXferMgr.log".to_owned(); - let new_fixture_path = temporary.root.join(&new_relative_path); - std::fs::create_dir_all( - new_fixture_path - .parent() - .expect("temporary replacement has a parent"), - ) - .expect("temporary replacement parent is created"); - std::fs::copy(temporary.root.join(old_relative_path), &new_fixture_path) - .expect("lo_ fragment is copied into another physical log family"); - manifest["artifacts"][1]["originalBasename"] = json!("PkgXferMgr.log"); - manifest["artifacts"][1]["sanitizedSourcePath"] = - json!("SYNTHETIC://site-root/Logs/PkgXferMgr.lo_"); - manifest["artifacts"][1]["relativePath"] = json!(new_relative_path); - - assert!( - !mutation_at_root_was_accepted("rotation-boundary", &temporary.root, &manifest, &expected,), - "distmgr current plus PkgXferMgr lo_ formed one rotation split" - ); -} - -#[test] -fn parse_failed_raw_bytes_remain_synthetic_and_identity_free() { - let temporary = temporary_scenario("rotation-boundary"); - let mut manifest = read_json("rotation-boundary", "manifest.json").expect("manifest loads"); - let expected = read_json("rotation-boundary", "expected.json").expect("expected loads"); - let relative_path = manifest["artifacts"][2]["relativePath"] - .as_str() - .expect("malformed artifact has a path") - .to_owned(); - let fixture_path = temporary.root.join(&relative_path); - let mut contents = - std::fs::read_to_string(&fixture_path).expect("malformed fixture is readable"); - contents - .push_str("real.user@example.com C:\\Users\\RealUser\\SMSDPProv.log secret=RealSecret\n"); - std::fs::write(&fixture_path, contents).expect("identity-bearing malformed bytes are written"); - refresh_artifact_bytes(&mut manifest, 2, &temporary.root); - - assert!( - !mutation_at_root_was_accepted("rotation-boundary", &temporary.root, &manifest, &expected,), - "parse-failed raw bytes retained uncited identity, path, and secret markers" - ); -} - -#[test] -fn observation_ids_reject_identity_bearing_values_across_output_classes() { - let healthy_manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let mut healthy_expected = - read_json("healthy-package", "expected.json").expect("expected loads"); - healthy_expected["transactions"][0]["observations"][0]["observationId"] = - json!("01-C:\\Users\\RealUser"); - - let rotation_manifest = - read_json("rotation-boundary", "manifest.json").expect("manifest loads"); - let mut rotation_expected = - read_json("rotation-boundary", "expected.json").expect("expected loads"); - rotation_expected["sourceLocalObservations"][0]["observationId"] = - json!("rotation-01-C:\\Users\\RealUser"); - - let accepted = [ - ( - "transaction observation ID", - mutation_was_accepted("healthy-package", &healthy_manifest, &healthy_expected), - ), - ( - "source-local observation ID", - mutation_was_accepted("rotation-boundary", &rotation_manifest, &rotation_expected), - ), - ] - .into_iter() - .filter_map(|(label, was_accepted)| was_accepted.then_some(label)) - .collect::>(); - - assert!( - accepted.is_empty(), - "identity-bearing public observation IDs were accepted: {accepted:?}" - ); -} - -#[test] -fn rotation_lineage_rejects_identity_bearing_values() { - let mut manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let expected = read_json("healthy-package", "expected.json").expect("expected loads"); - manifest["artifacts"][0]["rotation"]["lineageId"] = json!("C:\\Users\\RealUser\\distmgr"); - - assert!( - !mutation_was_accepted("healthy-package", &manifest, &expected), - "identity-bearing rotation lineage was accepted" - ); -} - -#[test] -fn path_fingerprints_are_bounded_declared_synthetic_identities() { - let manifest = read_json("healthy-package", "manifest.json").expect("manifest loads"); - let expected = read_json("healthy-package", "expected.json").expect("expected loads"); - let mut accepted = Vec::new(); - - let mut identity_bearing = manifest.clone(); - identity_bearing["artifacts"][0]["pathFingerprint"] = json!("synthetic:real-user-hostname"); - if mutation_was_accepted("healthy-package", &identity_bearing, &expected) { - accepted.push("identity-bearing fingerprint"); - } - - let mut oversized = manifest; - oversized["artifacts"][0]["pathFingerprint"] = json!(format!("synthetic:{}", "a".repeat(256))); - if mutation_was_accepted("healthy-package", &oversized, &expected) { - accepted.push("oversized fingerprint"); - } - - assert!( - accepted.is_empty(), - "unbounded or undeclared path fingerprints were accepted: {accepted:?}" - ); -} diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index edd904b5d..f6914c58f 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -6979,6 +6979,11 @@ fn catalog_requires_exact_producer_roles_for_server_workflow_sources() { SccmRole::DistributionPoint, SccmArtifactFamily::DistributionPoint, ), + ( + "SMSdpmon.log", + SccmRole::DistributionPoint, + SccmArtifactFamily::DistributionPoint, + ), ( "PullDP.log", SccmRole::DistributionPoint, @@ -7491,6 +7496,14 @@ fn expected_catalog_tuples() -> Vec { true, true, ), + ( + "SMSdpmon.log", + SccmRole::DistributionPoint, + "smsDpmon", + SccmArtifactFamily::DistributionPoint, + true, + true, + ), ( "PullDP.log", SccmRole::DistributionPoint, From adedf2def9b44069fb31cc074fd70772782498b3 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 04:23:53 -0400 Subject: [PATCH 387/422] fix(sccm): cite decisive DP contradiction evidence --- .../sccm/server/windows/distribution_point.rs | 78 +++++- .../tests/sccm_server_distribution_point.rs | 234 +++++++++++++++++- 2 files changed, 297 insertions(+), 15 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/distribution_point.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/distribution_point.rs index 23b520274..ea0c89bb3 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/distribution_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/distribution_point.rs @@ -625,6 +625,62 @@ fn selected_content_profile_site_code(topology_site_handle: &str) -> Option<&'st } } +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 { @@ -670,16 +726,18 @@ fn reduce_transaction( .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 envelope.facts.iter().any(|fact| fact.phase == phase) { + outcome = Some(if let Some(fact) = decisive { + selected.push(fact); SccmDistributionPointContentState::Contradictory } else { SccmDistributionPointContentState::Succeeded }); } else { - let has_non_monotonic_or_downstream = - envelope.facts.iter().any(|fact| fact.phase >= phase); - outcome = Some(if has_non_monotonic_or_downstream { + outcome = Some(if let Some(fact) = decisive { + selected.push(fact); SccmDistributionPointContentState::Contradictory } else { SccmDistributionPointContentState::Incomplete @@ -721,14 +779,10 @@ fn reduce_transaction( continue; } - let has_downstream = envelope.facts.iter().any(|fact| { - fact.phase > phase - && fact - .timestamp - .utc_millis - .is_some_and(|timestamp| timestamp > latest_timestamp) - }); - outcome = Some(if has_downstream { + 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 { diff --git a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs index 96dfc05c5..26074d893 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_distribution_point.rs @@ -4,9 +4,9 @@ use cmtraceopen_parser::sccm::server::windows::{ analyze_distribution_point, analyze_distribution_point_content_from_server_intake, assess_server_intake, SccmDistributionPointContentConfidence, SccmDistributionPointContentPhase, SccmDistributionPointContentState, - SccmServerArtifactPayload, SccmServerIntakeAssessment, SccmServerIntakeError, - SCCM_DISTRIBUTION_POINT_ANALYSIS_SCHEMA_VERSION, SCCM_DISTRIBUTION_POINT_INTAKE_PROFILE_ID, - SCCM_DISTRIBUTION_POINT_INTAKE_PROFILE_VERSION, + 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, @@ -26,6 +26,22 @@ fn artifact_request_contracts(requests: &[SccmArtifactRequest]) -> Vec<(&str, Sc .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 = [ @@ -180,6 +196,218 @@ fn unresolved_same_timestamp_outcomes_are_contradictory_and_request_one_source() ); } +#[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"); From 346d6584e593eb89dd03b2a91910e9430816f7b4 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 03:47:10 -0400 Subject: [PATCH 388/422] feat(sccm): add client policy state analysis --- .../src/sccm/client/admission.rs | 41 +- .../src/sccm/client/admission_tests.rs | 33 +- .../sccm/client/authority_contract_tests.rs | 47 +- .../cmtraceopen-parser/src/sccm/client/mod.rs | 2 + .../src/sccm/client/policy.rs | 1370 ++++ .../cmtraceopen-parser/src/sccm/findings.rs | 45 +- crates/cmtraceopen-parser/src/sccm/keys.rs | 126 +- crates/cmtraceopen-parser/src/sccm/models.rs | 1 + .../client/policy/production-oracles.json | 5896 +++++++++++++++++ .../tests/sccm_client_policy.rs | 470 ++ .../sccm_client_policy_fixture_contract.rs | 163 + .../2026-08-04-sccm-321-policy-production.md | 71 + library.md | 3 + 13 files changed, 8184 insertions(+), 84 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/client/policy.rs create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/production-oracles.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_policy.rs create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_policy_fixture_contract.rs create mode 100644 docs/superpowers/plans/2026-08-04-sccm-321-policy-production.md create mode 100644 library.md diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index 439b7ec59..d9ebdbde5 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -24,8 +24,9 @@ use crate::parser::ccm::scan_logical_records_bounded; use crate::sccm::catalog::classify_artifact_name; use crate::sccm::evidence::SccmRawEvidenceSnapshot; use crate::sccm::{ - extract_keys, SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmEvidence, - SccmExtractionProfile, SccmKeyExtractionResult, SccmRole, SccmRotation, SccmTimeOrderingState, + extract_admitted_keys, SccmArtifact, SccmArtifactFamily, SccmCoverageState, SccmEvidence, + SccmExtractionProfile, SccmExtractionProfileMaturity, SccmKeyExtractionResult, SccmRole, + SccmRotation, SccmTimeOrderingState, }; use super::{ @@ -193,19 +194,16 @@ impl SccmClientAdmittedEvidence { return Err(SccmClientEvidenceAdmissionError::IntegrityViolation); }; let mut extraction_profile = profile.clone(); - // Only Policy has executable key-extraction fixtures in the first - // client slice. Clearing this sealed family binding selects the public - // generic extractor without exposing a crate-wide privileged helper; - // the opaque result retains the verified family separately. All other - // families remain explicitly unvalidated. - if matches!(artifact_family, SccmArtifactFamily::ClientPolicy) { + if matches!(artifact_family, SccmArtifactFamily::ClientPolicy) + && profile.maturity == SccmExtractionProfileMaturity::Experimental + { extraction_profile.validated_artifact_families.clear(); } let results = self .evidence .iter() .filter(|evidence| evidence.reference.artifact_id == *sealed_artifact_id) - .map(|evidence| extract_keys(evidence, &extraction_profile)) + .map(|evidence| extract_admitted_keys(evidence, &extraction_profile)) .collect::>(); if results.is_empty() { return Err(SccmClientEvidenceAdmissionError::MissingAdmittedArtifactEvidence); @@ -322,8 +320,6 @@ pub enum SccmClientEvidenceAdmissionError { IntegritySealLimitExceeded, #[error("client evidence admission record timestamp provenance is not comparable")] InvalidTimestampProvenance, - #[error("client evidence admission selected an unregistered extraction profile")] - UnregisteredProfile, #[error("client evidence admission has no sealed extraction profile for the artifact")] MissingAdmittedExtractionProfile, #[error("client evidence admission has no sealed evidence for the artifact")] @@ -517,8 +513,7 @@ pub fn admit_client_evidence( let profile = SccmExtractionProfile::for_artifact_family( fragment.configmgr_version.as_deref(), family, - ) - .ok_or(SccmClientEvidenceAdmissionError::UnregisteredProfile)?; + ); let content = decode_payload(payload, fragment.encoding.as_deref())?; let artifact = artifact_for_fragment(fragment); let scan = @@ -541,9 +536,7 @@ pub fn admit_client_evidence( if normalized.evidence_id != normalized.reference.entry_id || normalized.reference.line_start.is_none() || normalized.reference.line_end.is_none() - || normalized.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc - || normalized.timestamp.offset_minutes.is_none() - || normalized.timestamp.utc_millis.is_none() + || !has_consistent_timestamp_provenance(&normalized) { return Err(SccmClientEvidenceAdmissionError::InvalidTimestampProvenance); } @@ -595,6 +588,22 @@ pub fn admit_client_evidence( }) } +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 validate_payload_budget( payloads: &[SccmClientCapturedPayload], ) -> Result<(), SccmClientEvidenceAdmissionError> { diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs index 21ce73964..598c37dba 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission_tests.rs @@ -7,7 +7,10 @@ use super::admission::{ }; use super::{assess_client_intake, SccmClientIntakeArtifact, SccmClientIntakeBundle}; use crate::parser::ccm::{observe_bounded_scans, CcmBoundedScanObservation}; -use crate::sccm::{SccmArtifact, SccmCoverageState, SccmRole, SccmRotation}; +use crate::sccm::{ + SccmArtifact, SccmCoverageState, SccmExtractionGapKind, SccmRole, SccmRotation, + SccmTimeOrderingState, +}; fn digest(bytes: &[u8]) -> String { Sha256::digest(bytes) @@ -429,7 +432,7 @@ fn admission_accepts_the_exact_cap_and_rejects_payload_overflow_before_reassessm } #[test] -fn admission_rejects_noncaptured_incomplete_malformed_and_invalid_offset_payloads() { +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); @@ -450,10 +453,16 @@ fn admission_rejects_noncaptured_incomplete_malformed_and_invalid_offset_payload 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"); - assert!( - admit_client_evidence(&unknown_profile, &unknown_profile_assessment, &[payload()],) - .is_err() - ); + 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); @@ -470,15 +479,21 @@ fn admission_rejects_noncaptured_incomplete_malformed_and_invalid_offset_payload 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"); - assert!(admit_client_evidence( + let invalid_offset = admit_client_evidence( &invalid_offset_bundle, &invalid_offset_assessment, &[payload_from_bytes( "fixture-policy-agent", invalid_offset_bytes, - )] + )], ) - .is_err()); + .expect("non-comparable time remains sealed evidence"); + assert_eq!( + invalid_offset.evidence().expect("sealed evidence")[0] + .timestamp + .ordering_state, + SccmTimeOrderingState::OffsetInvalid + ); } #[test] diff --git a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs index 7e81399c2..c2fccd71a 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs @@ -11,7 +11,7 @@ use super::{ use crate::sccm::{ extract_keys, SccmArtifact, SccmArtifactFamily, SccmCorrelationKeyKind, SccmCoverageState, SccmExtractionGapKind, SccmExtractionProfile, SccmExtractionProfileMaturity, SccmKeyConfidence, - SccmRole, SccmRotation, SCCM_EXPERIMENTAL_KEY_PROFILE_ID, + SccmRole, SccmRotation, SCCM_EXPERIMENTAL_KEY_PROFILE_ID, SCCM_POLICY_KEY_PROFILE_ID, }; fn digest(bytes: &[u8]) -> String { @@ -760,6 +760,51 @@ fn caller_constructed_policy_profile_cannot_cross_the_admitted_boundary() { .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 unregistered_ccm_family_is_admitted_with_an_unvalidated_profile_gap() { let bytes = ccm_bytes("Package ID = LAB00001"); diff --git a/crates/cmtraceopen-parser/src/sccm/client/mod.rs b/crates/cmtraceopen-parser/src/sccm/client/mod.rs index 1b84f430e..8fd06c416 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod admission; mod deployment; mod intake; mod inventory; +mod policy; mod updates; #[cfg(test)] @@ -26,4 +27,5 @@ pub use inventory::{ SccmClientExtendedPhase, SccmClientExtendedSourceCitation, SccmClientExtendedState, SccmClientExtendedTransaction, SccmClientExtendedWorkflow, }; +pub use policy::*; 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..0a71dbc72 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/policy.rs @@ -0,0 +1,1370 @@ +//! 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, +} + +#[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, + } + } 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()), + } + } else { + SccmPolicyExtractionProfile { + selection_state: SccmPolicyProfileSelectionState::Unavailable, + profile_id: None, + } + }; + + 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 inversion = phase_inversion(&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((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 let Some((earlier, later)) = inversion { + ( + later, + SccmPolicyState::Contradictory, + SccmPolicyClassification::ContradictoryEvidence, + Some(SccmPolicyCondition::OrderingUnavailable), + Some(earlier), + SccmConfidence::Low, + ) + } 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 phase_inversion(facts: &[PolicyFact]) -> Option<(SccmPolicyPhase, SccmPolicyPhase)> { + let comparable_successes = facts + .iter() + .filter(|fact| { + fact.outcome == SccmPolicyObservationOutcome::Succeeded + && fact.timestamp.ordering_state == SccmTimeOrderingState::NormalizedUtc + }) + .collect::>(); + for earlier in &comparable_successes { + for later in &comparable_successes { + if earlier.phase.rank() < later.phase.rank() + && 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 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/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index cc7748172..fa1ea05b4 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -40,7 +40,7 @@ const MAX_SCCM_FINDING_SUMMARY_CHARS: usize = 2048; pub(crate) const MAX_SCCM_CORRELATION_KEY_VALUE_CHARS: usize = 256; // Intentionally empty: no extraction profile is verified as stable enough to // authorize key-only High confidence. Adding one requires contract review. -const REGISTERED_STABLE_CORRELATION_PROFILE_IDS: &[&str] = &[]; +const REGISTERED_STABLE_CORRELATION_PROFILE_IDS: &[&str] = &["policy-client-5.00.test-v1"]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -2681,27 +2681,28 @@ fn coverage_state_order(coverage: &SccmCoverageState) -> u8 { fn correlation_key_kind_order(kind: &SccmCorrelationKeyKind) -> u8 { match kind { SccmCorrelationKeyKind::AssignmentId => 0, - SccmCorrelationKeyKind::ClientGuid => 1, - SccmCorrelationKeyKind::PackageId => 2, - SccmCorrelationKeyKind::ContentId => 3, - SccmCorrelationKeyKind::SiteCode => 4, - SccmCorrelationKeyKind::ServerHost => 5, - SccmCorrelationKeyKind::CiId => 6, - SccmCorrelationKeyKind::UpdateId => 7, - SccmCorrelationKeyKind::KbId => 8, - SccmCorrelationKeyKind::BitsJobId => 9, - SccmCorrelationKeyKind::TaskSequenceExecutionId => 10, - SccmCorrelationKeyKind::RequestId => 11, - SccmCorrelationKeyKind::TopicId => 12, - SccmCorrelationKeyKind::StateMessageId => 13, - SccmCorrelationKeyKind::InventoryCycleId => 14, - SccmCorrelationKeyKind::ReportId => 15, - SccmCorrelationKeyKind::ResourceHandle => 16, - SccmCorrelationKeyKind::ComplianceCiId => 17, - SccmCorrelationKeyKind::BaselineId => 18, - SccmCorrelationKeyKind::ComplianceStateId => 19, - SccmCorrelationKeyKind::MeteringCycleId => 20, - SccmCorrelationKeyKind::RuleId => 21, + 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::InventoryCycleId => 15, + SccmCorrelationKeyKind::ReportId => 16, + SccmCorrelationKeyKind::ResourceHandle => 17, + SccmCorrelationKeyKind::ComplianceCiId => 18, + SccmCorrelationKeyKind::BaselineId => 19, + SccmCorrelationKeyKind::ComplianceStateId => 20, + SccmCorrelationKeyKind::MeteringCycleId => 21, + SccmCorrelationKeyKind::RuleId => 22, } } diff --git a/crates/cmtraceopen-parser/src/sccm/keys.rs b/crates/cmtraceopen-parser/src/sccm/keys.rs index b2e9fc03b..c028ca729 100644 --- a/crates/cmtraceopen-parser/src/sccm/keys.rs +++ b/crates/cmtraceopen-parser/src/sccm/keys.rs @@ -11,7 +11,9 @@ use super::models::{ }; pub const SCCM_EXPERIMENTAL_KEY_PROFILE_ID: &str = "sccm-keys-5.00.9128-experimental-v1"; +pub const SCCM_POLICY_KEY_PROFILE_ID: &str = "policy-client-5.00.test-v1"; const EXPERIMENTAL_VERSION_PREFIX: &str = "5.00.9128."; +const POLICY_TEST_VERSION: &str = "5.00.TEST.0000"; impl SccmExtractionProfile { pub fn for_version(configmgr_version: Option<&str>) -> Self { @@ -59,13 +61,21 @@ impl SccmExtractionProfile { pub(crate) fn for_artifact_family( configmgr_version: Option<&str>, family: &SccmArtifactFamily, - ) -> Option { - let mut profile = Self::for_version(configmgr_version); - if !is_builtin_experimental_core(&profile) { - return None; + ) -> Self { + 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()]; - Some(profile) + profile } } @@ -88,7 +98,11 @@ fn key_patterns() -> &'static [KeyPattern] { [ ( SccmCorrelationKeyKind::AssignmentId, - r"(?i:\b(?:assignment|policy)[ \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_.-]*)", + 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, @@ -169,6 +183,21 @@ pub fn normalize_key(kind: SccmCorrelationKeyKind, raw: &str) -> SccmCorrelation 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); let mut result = SccmKeyExtractionResult { @@ -177,7 +206,7 @@ pub fn extract_keys( gaps: Vec::new(), }; - if let Some(kind) = profile_gap_kind(profile) { + 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 { @@ -190,12 +219,15 @@ pub fn extract_keys( return result; } - result.gaps.push(gap_for( - SccmExtractionGapKind::ExperimentalProfile, - profile, - evidence, - None, - )); + let stable_policy = admitted_profile_authority && is_builtin_stable_policy(profile); + if !stable_policy { + result.gaps.push(gap_for( + SccmExtractionGapKind::ExperimentalProfile, + profile, + evidence, + None, + )); + } for candidate in candidates { let mut key = normalize_key(candidate.kind.clone(), candidate.raw); @@ -214,7 +246,11 @@ pub fn extract_keys( continue; } - key.confidence = SccmKeyConfidence::Low; + key.confidence = if stable_policy { + 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); @@ -233,7 +269,10 @@ fn is_bounded_key_value(key: &SccmCorrelationKey) -> bool { && has_at_most_chars(&key.normalized, MAX_SCCM_CORRELATION_KEY_VALUE_CHARS) } -fn profile_gap_kind(profile: &SccmExtractionProfile) -> Option { +fn profile_gap_kind( + profile: &SccmExtractionProfile, + admitted_profile_authority: bool, +) -> Option { if profile.selected_configmgr_version.is_none() { return Some(SccmExtractionGapKind::MissingVersion); } @@ -243,12 +282,25 @@ fn profile_gap_kind(profile: &SccmExtractionProfile) -> Option None, + SccmExtractionProfileMaturity::Stable + if admitted_profile_authority && is_builtin_stable_policy(profile) => + { + None + } SccmExtractionProfileMaturity::Experimental | SccmExtractionProfileMaturity::Stable => { Some(SccmExtractionGapKind::UnvalidatedProfile) } } } +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 @@ -360,6 +412,7 @@ fn normalize_value(kind: &SccmCorrelationKeyKind, raw: &str) -> (String, SccmKey let trimmed = raw.trim(); let normalized = match kind { SccmCorrelationKeyKind::AssignmentId + | SccmCorrelationKeyKind::PolicyId | SccmCorrelationKeyKind::ClientGuid | SccmCorrelationKeyKind::UpdateId | SccmCorrelationKeyKind::BitsJobId @@ -492,26 +545,27 @@ fn normalize_resource_handle(value: &str) -> Option { fn key_kind_order(kind: &SccmCorrelationKeyKind) -> u8 { match kind { SccmCorrelationKeyKind::AssignmentId => 0, - SccmCorrelationKeyKind::ClientGuid => 1, - SccmCorrelationKeyKind::PackageId => 2, - SccmCorrelationKeyKind::ContentId => 3, - SccmCorrelationKeyKind::SiteCode => 4, - SccmCorrelationKeyKind::ServerHost => 5, - SccmCorrelationKeyKind::CiId => 6, - SccmCorrelationKeyKind::UpdateId => 7, - SccmCorrelationKeyKind::KbId => 8, - SccmCorrelationKeyKind::BitsJobId => 9, - SccmCorrelationKeyKind::TaskSequenceExecutionId => 10, - SccmCorrelationKeyKind::RequestId => 11, - SccmCorrelationKeyKind::TopicId => 12, - SccmCorrelationKeyKind::StateMessageId => 13, - SccmCorrelationKeyKind::InventoryCycleId => 14, - SccmCorrelationKeyKind::ReportId => 15, - SccmCorrelationKeyKind::ResourceHandle => 16, - SccmCorrelationKeyKind::ComplianceCiId => 17, - SccmCorrelationKeyKind::BaselineId => 18, - SccmCorrelationKeyKind::ComplianceStateId => 19, - SccmCorrelationKeyKind::MeteringCycleId => 20, - SccmCorrelationKeyKind::RuleId => 21, + 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::InventoryCycleId => 15, + SccmCorrelationKeyKind::ReportId => 16, + SccmCorrelationKeyKind::ResourceHandle => 17, + SccmCorrelationKeyKind::ComplianceCiId => 18, + SccmCorrelationKeyKind::BaselineId => 19, + SccmCorrelationKeyKind::ComplianceStateId => 20, + SccmCorrelationKeyKind::MeteringCycleId => 21, + SccmCorrelationKeyKind::RuleId => 22, } } diff --git a/crates/cmtraceopen-parser/src/sccm/models.rs b/crates/cmtraceopen-parser/src/sccm/models.rs index 47c4a5e05..1b8984a82 100644 --- a/crates/cmtraceopen-parser/src/sccm/models.rs +++ b/crates/cmtraceopen-parser/src/sccm/models.rs @@ -183,6 +183,7 @@ pub struct SccmEvidence { #[serde(rename_all = "camelCase")] pub enum SccmCorrelationKeyKind { AssignmentId, + PolicyId, ClientGuid, PackageId, ContentId, 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..2774aa5ed --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/production-oracles.json @@ -0,0 +1,5896 @@ +{ + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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" + }, + "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/sccm_client_policy.rs b/crates/cmtraceopen-parser/tests/sccm_client_policy.rs new file mode 100644 index 000000000..5763500e6 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_policy.rs @@ -0,0 +1,470 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::{ + analyze_client_policy, assess_client_intake, SccmArtifact, SccmClientCapturedPayload, + SccmClientIntakeArtifact, SccmClientIntakeBundle, SccmClientIntakeCaptureGap, SccmConfidence, + SccmCoverageState, SccmKeyConfidence, SccmPolicyClassification, SccmPolicyCondition, + SccmPolicyProfileSelectionState, SccmPolicyState, SccmRole, SccmRotation, + SCCM_POLICY_KEY_PROFILE_ID, +}; +use serde::Deserialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +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", +]; + +#[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, + 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_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")); +} 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/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/library.md b/library.md new file mode 100644 index 000000000..9afee05b6 --- /dev/null +++ b/library.md @@ -0,0 +1,3 @@ +# 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]] From a313e66daddade1369f788be5972c2d57c23faec Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 04:04:42 -0400 Subject: [PATCH 389/422] fix(sccm): prioritize policy chronology conflicts --- .../src/sccm/client/policy.rs | 56 +++++---- .../cmtraceopen-parser/src/sccm/findings.rs | 5 +- crates/cmtraceopen-parser/src/sccm/keys.rs | 3 + .../client/policy/production-oracles.json | 44 ++++--- .../tests/sccm_client_policy.rs | 116 +++++++++++++++++- 5 files changed, 181 insertions(+), 43 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/policy.rs b/crates/cmtraceopen-parser/src/sccm/client/policy.rs index 0a71dbc72..9fd3c155b 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/policy.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/policy.rs @@ -130,6 +130,10 @@ 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)] @@ -425,6 +429,7 @@ fn reduce_policy( SccmPolicyExtractionProfile { selection_state: SccmPolicyProfileSelectionState::UnvalidatedVersion, profile_id: None, + synthetic_fixture_only: true, } } else if assessment .physical_artifacts @@ -434,11 +439,13 @@ fn reduce_policy( 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, } }; @@ -554,7 +561,7 @@ fn reduce_transaction( representatives.insert(phase, representative.reference.clone()); } - let inversion = phase_inversion(&facts); + 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)) @@ -562,7 +569,16 @@ fn reduce_transaction( }); let (phase, state, classification, condition, last_confirmed_phase, mut confidence) = - if let Some((phase, resolution)) = decisive { + if let Some((earlier, later)) = chronology_conflict { + ( + later, + SccmPolicyState::Contradictory, + SccmPolicyClassification::ContradictoryEvidence, + Some(SccmPolicyCondition::OrderingUnavailable), + Some(earlier), + SccmConfidence::Low, + ) + } else if let Some((phase, resolution)) = decisive { let last = last_success_before(&resolutions, phase); match resolution { PhaseResolution::Failed => { @@ -632,15 +648,6 @@ fn reduce_transaction( ), PhaseResolution::Succeeded => unreachable!(), } - } else if let Some((earlier, later)) = inversion { - ( - later, - SccmPolicyState::Contradictory, - SccmPolicyClassification::ContradictoryEvidence, - Some(SccmPolicyCondition::OrderingUnavailable), - Some(earlier), - SccmConfidence::Low, - ) } else if resolutions.get(&SccmPolicyPhase::Report) == Some(&PhaseResolution::Succeeded) { ( SccmPolicyPhase::Report, @@ -852,19 +859,20 @@ fn resolution_for_outcome(outcome: SccmPolicyObservationOutcome) -> PhaseResolut } } -fn phase_inversion(facts: &[PolicyFact]) -> Option<(SccmPolicyPhase, SccmPolicyPhase)> { - let comparable_successes = facts - .iter() - .filter(|fact| { - fact.outcome == SccmPolicyObservationOutcome::Succeeded - && fact.timestamp.ordering_state == SccmTimeOrderingState::NormalizedUtc - }) - .collect::>(); - for earlier in &comparable_successes { - for later in &comparable_successes { - if earlier.phase.rank() < later.phase.rank() - && earlier.timestamp.utc_millis > later.timestamp.utc_millis - { +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)); } } diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index fa1ea05b4..bfcfe4e56 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -38,8 +38,9 @@ const MAX_SCCM_FINDING_SUMMARY_CHARS: usize = 2048; // 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; -// Intentionally empty: no extraction profile is verified as stable enough to -// authorize key-only High confidence. Adding one requires contract review. +// 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"]; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/cmtraceopen-parser/src/sccm/keys.rs b/crates/cmtraceopen-parser/src/sccm/keys.rs index c028ca729..11fc82624 100644 --- a/crates/cmtraceopen-parser/src/sccm/keys.rs +++ b/crates/cmtraceopen-parser/src/sccm/keys.rs @@ -11,6 +11,9 @@ use super::models::{ }; 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"; const EXPERIMENTAL_VERSION_PREFIX: &str = "5.00.9128."; const POLICY_TEST_VERSION: &str = "5.00.TEST.0000"; 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 index 2774aa5ed..14b8518c1 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/production-oracles.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/production-oracles.json @@ -22,7 +22,8 @@ "crossSourceCorrelationPerformed": true, "extractionProfile": { "profileId": "policy-client-5.00.test-v1", - "selectionState": "selected" + "selectionState": "selected", + "syntheticFixtureOnly": true }, "findings": [], "profileGaps": [], @@ -448,7 +449,8 @@ "crossSourceCorrelationPerformed": true, "extractionProfile": { "profileId": "policy-client-5.00.test-v1", - "selectionState": "selected" + "selectionState": "selected", + "syntheticFixtureOnly": true }, "findings": [ { @@ -1198,7 +1200,7 @@ "policyId": "b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8", "requestId": "27262626-2626-2626-2626-262626262626" }, - "lastConfirmedPhase": "schedule", + "lastConfirmedPhase": "request", "nextArtifacts": [ { "logicalId": "ciAgent", @@ -1342,7 +1344,8 @@ "crossSourceCorrelationPerformed": false, "extractionProfile": { "profileId": "policy-client-5.00.test-v1", - "selectionState": "selected" + "selectionState": "selected", + "syntheticFixtureOnly": true }, "findings": [ { @@ -1608,7 +1611,8 @@ "crossSourceCorrelationPerformed": true, "extractionProfile": { "profileId": "policy-client-5.00.test-v1", - "selectionState": "selected" + "selectionState": "selected", + "syntheticFixtureOnly": true }, "findings": [ { @@ -2044,7 +2048,8 @@ "crossSourceCorrelationPerformed": true, "extractionProfile": { "profileId": "policy-client-5.00.test-v1", - "selectionState": "selected" + "selectionState": "selected", + "syntheticFixtureOnly": true }, "findings": [ { @@ -3010,7 +3015,8 @@ "crossSourceCorrelationPerformed": false, "extractionProfile": { "profileId": "policy-client-5.00.test-v1", - "selectionState": "selected" + "selectionState": "selected", + "syntheticFixtureOnly": true }, "findings": [ { @@ -3400,7 +3406,8 @@ ], "crossSourceCorrelationPerformed": false, "extractionProfile": { - "selectionState": "unvalidatedVersion" + "selectionState": "unvalidatedVersion", + "syntheticFixtureOnly": true }, "findings": [], "profileGaps": [ @@ -3480,7 +3487,8 @@ "crossSourceCorrelationPerformed": true, "extractionProfile": { "profileId": "policy-client-5.00.test-v1", - "selectionState": "selected" + "selectionState": "selected", + "syntheticFixtureOnly": true }, "findings": [], "profileGaps": [], @@ -3895,7 +3903,8 @@ "crossSourceCorrelationPerformed": false, "extractionProfile": { "profileId": "policy-client-5.00.test-v1", - "selectionState": "selected" + "selectionState": "selected", + "syntheticFixtureOnly": true }, "findings": [ { @@ -4216,7 +4225,8 @@ "crossSourceCorrelationPerformed": true, "extractionProfile": { "profileId": "policy-client-5.00.test-v1", - "selectionState": "selected" + "selectionState": "selected", + "syntheticFixtureOnly": true }, "findings": [], "profileGaps": [], @@ -4689,7 +4699,8 @@ "crossSourceCorrelationPerformed": true, "extractionProfile": { "profileId": "policy-client-5.00.test-v1", - "selectionState": "selected" + "selectionState": "selected", + "syntheticFixtureOnly": true }, "findings": [ { @@ -5174,7 +5185,8 @@ "crossSourceCorrelationPerformed": false, "extractionProfile": { "profileId": "policy-client-5.00.test-v1", - "selectionState": "selected" + "selectionState": "selected", + "syntheticFixtureOnly": true }, "findings": [ { @@ -5444,7 +5456,8 @@ "crossSourceCorrelationPerformed": false, "extractionProfile": { "profileId": "policy-client-5.00.test-v1", - "selectionState": "selected" + "selectionState": "selected", + "syntheticFixtureOnly": true }, "findings": [], "profileGaps": [], @@ -5509,7 +5522,8 @@ "crossSourceCorrelationPerformed": false, "extractionProfile": { "profileId": "policy-client-5.00.test-v1", - "selectionState": "selected" + "selectionState": "selected", + "syntheticFixtureOnly": true }, "findings": [ { diff --git a/crates/cmtraceopen-parser/tests/sccm_client_policy.rs b/crates/cmtraceopen-parser/tests/sccm_client_policy.rs index 5763500e6..4a0713ba5 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_policy.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_policy.rs @@ -5,8 +5,8 @@ use std::path::{Path, PathBuf}; use cmtraceopen_parser::sccm::{ analyze_client_policy, assess_client_intake, SccmArtifact, SccmClientCapturedPayload, SccmClientIntakeArtifact, SccmClientIntakeBundle, SccmClientIntakeCaptureGap, SccmConfidence, - SccmCoverageState, SccmKeyConfidence, SccmPolicyClassification, SccmPolicyCondition, - SccmPolicyProfileSelectionState, SccmPolicyState, SccmRole, SccmRotation, + SccmCoverageState, SccmFindingClass, SccmKeyConfidence, SccmPolicyClassification, + SccmPolicyCondition, SccmPolicyProfileSelectionState, SccmPolicyState, SccmRole, SccmRotation, SCCM_POLICY_KEY_PROFILE_ID, }; use serde::Deserialize; @@ -263,6 +263,11 @@ fn every_policy_fixture_runs_through_production_with_exact_oracles() { #[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 @@ -468,3 +473,110 @@ fn capped_policy_source_is_an_explicit_transaction_gap() { .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 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()); + } +} From dcc8e560f5b4b027161f680f2777bbdb1907874a Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 04:32:37 -0400 Subject: [PATCH 390/422] fix(sccm): confirm only ordered policy successes --- .../src/sccm/client/policy.rs | 36 +++++++++++++- .../client/policy/production-oracles.json | 2 +- .../tests/sccm_client_policy.rs | 47 ++++++++++++++++++- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/policy.rs b/crates/cmtraceopen-parser/src/sccm/client/policy.rs index 9fd3c155b..89694b496 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/policy.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/policy.rs @@ -569,13 +569,13 @@ fn reduce_transaction( }); let (phase, state, classification, condition, last_confirmed_phase, mut confidence) = - if let Some((earlier, later)) = chronology_conflict { + if let Some((_earlier, later)) = chronology_conflict { ( later, SccmPolicyState::Contradictory, SccmPolicyClassification::ContradictoryEvidence, Some(SccmPolicyCondition::OrderingUnavailable), - Some(earlier), + last_confirmed_successful_prefix(&facts), SccmConfidence::Low, ) } else if let Some((phase, resolution)) = decisive { @@ -908,6 +908,38 @@ fn last_contiguous_success( 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, 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 index 14b8518c1..9e5ca9947 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/production-oracles.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/production-oracles.json @@ -1200,7 +1200,7 @@ "policyId": "b8b8b8b8-b8b8-b8b8-b8b8-b8b8b8b8b8b8", "requestId": "27262626-2626-2626-2626-262626262626" }, - "lastConfirmedPhase": "request", + "lastConfirmedPhase": "schedule", "nextArtifacts": [ { "logicalId": "ciAgent", diff --git a/crates/cmtraceopen-parser/tests/sccm_client_policy.rs b/crates/cmtraceopen-parser/tests/sccm_client_policy.rs index 4a0713ba5..c16800c78 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_policy.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_policy.rs @@ -6,8 +6,8 @@ use cmtraceopen_parser::sccm::{ analyze_client_policy, assess_client_intake, SccmArtifact, SccmClientCapturedPayload, SccmClientIntakeArtifact, SccmClientIntakeBundle, SccmClientIntakeCaptureGap, SccmConfidence, SccmCoverageState, SccmFindingClass, SccmKeyConfidence, SccmPolicyClassification, - SccmPolicyCondition, SccmPolicyProfileSelectionState, SccmPolicyState, SccmRole, SccmRotation, - SCCM_POLICY_KEY_PROFILE_ID, + SccmPolicyCondition, SccmPolicyPhase, SccmPolicyProfileSelectionState, SccmPolicyState, + SccmRole, SccmRotation, SCCM_POLICY_KEY_PROFILE_ID, }; use serde::Deserialize; use serde_json::Value; @@ -540,6 +540,49 @@ fn cross_phase_chronology_precedes_failure_deferred_and_success_decisions() { } } +#[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 = [ From a736ccbdbab66ee6808e69c32b65ff3d7113db0a Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 05:10:38 -0400 Subject: [PATCH 391/422] test(sccm): reduce admitted unorderable deployments --- .../tests/sccm_client_deployment.rs | 187 +++++++++++------- 1 file changed, 118 insertions(+), 69 deletions(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index b8f34f421..e412505e8 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -1213,7 +1213,7 @@ fn canonical_client_admission_rejects_an_artifact_from_another_role() { } #[test] -fn canonical_admission_rejects_unorderable_terminal_records() { +fn reducer_fails_closed_on_admitted_unorderable_terminal_records() { let failing_transfer = format!( "{}{}", record( @@ -1231,24 +1231,41 @@ fn canonical_admission_rejects_unorderable_terminal_records() { "DataTransferService", ), ); - assert!( - 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, - ), - ]) - .is_err(), - "timestamp provenance must fail before reducer authority exists" + 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] @@ -2067,7 +2084,7 @@ fn a_punctuation_adjacent_duplicate_label_is_ambiguity_not_a_first_win() { } #[test] -fn canonical_admission_rejects_cross_phase_records_with_unorderable_timestamps() { +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" @@ -2090,36 +2107,56 @@ fn canonical_admission_rejects_cross_phase_records_with_unorderable_timestamps() "AppEnforce", ); - assert!( - 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, + 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), ), - ]) - .is_err(), - "unorderable records must fail before reducer authority exists" - ); + 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] @@ -2282,7 +2319,7 @@ fn a_repeated_identical_content_request_publishes_the_earliest_record_only() { } #[test] -fn canonical_admission_rejects_an_unorderable_repeated_content_request() { +fn reducer_suppresses_an_admitted_unorderable_repeated_content_request() { let located = |time: &str| { record( &format!( @@ -2292,26 +2329,38 @@ fn canonical_admission_rejects_an_unorderable_repeated_content_request() { "CAS", ) }; - assert!( - 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"), + 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), ), - ]) - .is_err(), - "incomparable record timestamps must fail before reducer authority exists" + 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())); } From 8da65cbdcc29245a3371ddde9ece77b44c754c7b Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 03:28:09 -0400 Subject: [PATCH 392/422] feat(sccm): add production client health correlation --- .../src/sccm/client/admission.rs | 11 +- .../src/sccm/client/health.rs | 881 ++++++++++++++++++ .../cmtraceopen-parser/src/sccm/client/mod.rs | 2 + .../client-ccmsetup/current/ccmsetup.log | 1 + .../client-evaluation/current/CcmEval.log | 3 + .../current/ClientIDManagerStartup.log | 2 + .../authentication-failure/expected.json | 173 ++++ .../authentication-failure/manifest.json | 11 + .../client-ccmsetup/current/ccmsetup.log | 1 + .../client-evaluation/current/CcmEval.log | 3 + .../current/ClientIDManagerStartup.log | 2 + .../current/LocationServices.log | 2 + .../boundary-location-failure/expected.json | 197 ++++ .../boundary-location-failure/manifest.json | 11 + .../client-ccmsetup/current/ccmsetup.log | 4 +- .../client/health/contradictory/expected.json | 125 ++- .../client/health/contradictory/manifest.json | 10 +- .../client-ccmsetup/current/ccmsetup.log | 2 +- .../client-evaluation/current/CcmEval.log | 4 +- .../current/ClientIDManagerStartup.log | 2 +- .../health/identity-failure/expected.json | 171 +++- .../health/identity-failure/manifest.json | 10 +- .../client-ccmsetup/current/ccmsetup.log | 2 +- .../client-evaluation/current/CcmEval.log | 4 +- .../client/health/incomplete/expected.json | 148 ++- .../client/health/incomplete/manifest.json | 10 +- .../client-ccmsetup/current/ccmsetup.log | 2 +- .../client-evaluation/current/CcmEval.log | 2 +- .../client/health/malformed/expected.json | 112 ++- .../client/health/malformed/manifest.json | 10 +- .../client-ccmsetup/current/ccmsetup.log | 2 +- .../client-evaluation/current/CcmEval.log | 4 +- .../current/ClientIDManagerStartup.log | 3 +- .../current/LocationServices.log | 6 +- .../client/health/no-site-or-mp/expected.json | 186 +++- .../client/health/no-site-or-mp/manifest.json | 10 +- .../health/rotation-boundary/expected.json | 114 ++- .../health/rotation-boundary/manifest.json | 12 +- .../client-ccmsetup/current/ccmsetup.log | 2 +- .../client/health/setup-failure/expected.json | 123 ++- .../client/health/setup-failure/manifest.json | 10 +- .../client-ccmsetup/current/ccmsetup.log | 2 +- .../client-evaluation/current/CcmEval.log | 4 +- .../current/ClientIDManagerStartup.log | 3 +- .../current/LocationServices.log | 9 +- .../sccm/client/health/success/expected.json | 189 +++- .../sccm/client/health/success/manifest.json | 10 +- .../client-ccmsetup/current/ccmsetup.log | 2 +- .../client-evaluation/current/CcmEval.log | 4 +- .../current/ClientIDManagerStartup.log | 3 +- .../current/LocationServices.log | 9 +- .../health/transport-failure/expected.json | 231 ++++- .../health/transport-failure/manifest.json | 10 +- .../tests/sccm_client_health.rs | 576 ++++++++++++ .../sccm_client_health_fixture_contract.rs | 435 --------- 55 files changed, 3256 insertions(+), 621 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/client/health.rs create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/evidence/client-ccmsetup/current/ccmsetup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/evidence/client-evaluation/current/CcmEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/evidence/client-identity/current/ClientIDManagerStartup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/authentication-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-ccmsetup/current/ccmsetup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-evaluation/current/CcmEval.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-identity/current/ClientIDManagerStartup.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/evidence/client-location-services-shared/current/LocationServices.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/boundary-location-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_health.rs delete mode 100644 crates/cmtraceopen-parser/tests/sccm_client_health_fixture_contract.rs diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index d9ebdbde5..ade237ec3 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -194,8 +194,15 @@ impl SccmClientAdmittedEvidence { return Err(SccmClientEvidenceAdmissionError::IntegrityViolation); }; let mut extraction_profile = profile.clone(); - if matches!(artifact_family, SccmArtifactFamily::ClientPolicy) - && profile.maturity == SccmExtractionProfileMaturity::Experimental + 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(); } 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..52b3a96ef --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/health.rs @@ -0,0 +1,881 @@ +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, +} + +#[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) => { + chain.client_guid = fact.keys.client_guid.clone(); + 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 candidates = facts + .iter() + .filter(|fact| fact.phase == phase && fact_matches_chain(fact, chain)) + .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; + } + let terminal = candidates + .iter() + .copied() + .filter(|fact| fact.terminal) + .collect::>(); + if terminal.is_empty() { + return Resolution::Pending(candidates); + } + if phase.is_none() + && terminal + .iter() + .map(|fact| fact.phase) + .collect::>() + .len() + != 1 + { + return Resolution::Contradictory(terminal); + } + if phase.is_none() + && terminal + .iter() + .filter_map(|fact| fact.keys.client_guid.as_deref()) + .collect::>() + .len() + != 1 + { + return Resolution::Contradictory(terminal); + } + let last = *terminal + .iter() + .max_by_key(|fact| (fact.utc_millis, reference_identity(&fact.reference))) + .expect("terminal set is nonempty"); + if terminal + .iter() + .any(|fact| fact.utc_millis == last.utc_millis && fact.disposition != last.disposition) + { + return Resolution::Contradictory(terminal); + } + 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 fact_matches_chain(fact: &HealthFact, chain: &ChainKeys) -> bool { + 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, + SccmClientHealthPhase::Transport => { + fact.keys.management_point_host == chain.management_point_host + } + phase if phase.is_lifecycle() => true, + _ => false, + } +} + +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(); + } +} + +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/mod.rs b/crates/cmtraceopen-parser/src/sccm/client/mod.rs index 8fd06c416..2a574effd 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod admission; mod deployment; +mod health; mod intake; mod inventory; mod policy; @@ -20,6 +21,7 @@ pub use admission::{ SccmClientEvidenceAdmissionError, }; pub use deployment::*; +pub use health::*; pub use intake::*; pub use inventory::{ analyze_client_extended, SccmClientExtendedAnalysis, SccmClientExtendedArtifactRequest, 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 index 046fba428..28732291f 100644 --- 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 @@ -1,2 +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 index ff37b5ec0..0d1200f04 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/expected.json @@ -1,12 +1,115 @@ { - "contractState": "proposedPending318And319", - "scenario": "contradictory", - "workflow": "health", - "workflowDiagnosisExpected": true, - "lastSuccessfulPhase": null, - "findings": [ - {"findingId":"health-setup-contradictory","class":"symptom","phase":"setup","role":"client","confidence":"low","fixtureEvidence":[{"artifactId":"health-contradictory-ccmsetup-current","entryId":"entry-000001","lineStart":1,"lineEnd":1},{"artifactId":"health-contradictory-ccmsetup-current","entryId":"entry-000002","lineStart":2,"lineEnd":2}],"coverageGapArtifactIds":[],"nextArtifacts":[{"logicalArtifactId":"client-ccmsetup","reason":"capture a complete ordered bootstrap sequence for one validated bootstrapId"}],"mustNotClaim":["recovered setup","confirmed setup failure","management point cause"]} - ], - "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"absent"},{"logicalArtifactId":"client-identity","state":"absent"},{"logicalArtifactId":"client-location","state":"absent"}], - "assertions": ["later success with bootstrapId BOOT-TEST-B cannot recover terminal BOOT-TEST-A","same-key ordered recovery is a separate focused mutation"] -} + "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 index 27f23e0ef..472de1ea1 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/contradictory/manifest.json @@ -1,13 +1,11 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, "scenario": "contradictory", - "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":"health-contradictory-ccmsetup-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-root-a-health-contradictory-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:50:02Z","bytesCopied":461,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, - {"artifactId":"health-contradictory-evaluation-absent","designOnlyCatalog":{"entryId":"client-evaluation","groupMemberships":["client-evaluation"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"CcmEval.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-contradictory-evaluation-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:50:01Z","bytesCopied":0,"relativePath":null}, - {"artifactId":"health-contradictory-identity-absent","designOnlyCatalog":{"entryId":"client-identity","groupMemberships":["client-identity"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"ClientIDManagerStartup.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-contradictory-identity-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:50:02Z","bytesCopied":0,"relativePath":null}, - {"artifactId":"health-contradictory-location-services-absent","designOnlyCatalog":{"entryId":"client-location-services-shared","groupMemberships":["client-content","client-location"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"LocationServices.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-contradictory-location-services-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:50:03Z","bytesCopied":0,"relativePath":null} + {"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 index 7e5375ba8..ac649938f 100644 --- 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 @@ -1 +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 index 5ca45f9f3..4e0bdfb29 100644 --- 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 @@ -1 +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 index 6ec06a89c..2befb51e9 100644 --- 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 @@ -1 +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 index c7d86898d..e59080815 100644 --- 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 @@ -1,12 +1,161 @@ { - "contractState": "proposedPending318And319", - "scenario": "identity-failure", - "workflow": "health", - "workflowDiagnosisExpected": true, - "lastSuccessfulPhase": "service", - "findings": [ - {"findingId":"health-identity-terminal","class":"confirmedFailure","phase":"identity","role":"client","confidence":"high","fixtureEvidence":[{"artifactId":"health-identity-failure-identity-current","entryId":"entry-000001","lineStart":1,"lineEnd":1}],"coverageGapArtifactIds":[],"nextArtifacts":[],"mustNotClaim":["site assignment failure","management point failure","management point cause"]} - ], - "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"captured"},{"logicalArtifactId":"client-identity","state":"captured"},{"logicalArtifactId":"client-location","state":"absent"}], - "assertions": ["identity terminal record carries the validated clientGuid","site and MP are not inferred after identity failure"] -} + "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 index e4df8658b..61cd8e7e6 100644 --- 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 @@ -1,13 +1,11 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, "scenario": "identity-failure", - "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":"health-identity-failure-ccmsetup-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-root-a-health-identity-failure-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:20:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, - {"artifactId":"health-identity-failure-evaluation-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-root-a-health-identity-failure-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:20:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, - {"artifactId":"health-identity-failure-identity-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-root-a-health-identity-failure-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:20:02Z","bytesCopied":290,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, - {"artifactId":"health-identity-failure-location-services-absent","designOnlyCatalog":{"entryId":"client-location-services-shared","groupMemberships":["client-content","client-location"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"LocationServices.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-identity-failure-location-services-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:20:03Z","bytesCopied":0,"relativePath":null} + {"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 index 6e8a3364d..11bd172d0 100644 --- 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 @@ -1 +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 index d42a8fc98..59edabda2 100644 --- 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 @@ -1 +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 index bb2cc3ddb..a9a2f53cb 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/expected.json @@ -1,12 +1,138 @@ { - "contractState": "proposedPending318And319", - "scenario": "incomplete", - "workflow": "health", - "workflowDiagnosisExpected": true, - "lastSuccessfulPhase": "service", - "findings": [ - {"findingId":"health-identity-coverage-gap","class":"insufficientEvidence","phase":"identity","role":"client","confidence":"low","fixtureEvidence":[],"coverageGapArtifactIds":["health-incomplete-identity-access-denied"],"nextArtifacts":[{"logicalArtifactId":"client-identity","reason":"capture the identity registration outcome with readable source coverage"}],"mustNotClaim":["identity failure","site assignment failure","management point cause"]} - ], - "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"captured"},{"logicalArtifactId":"client-identity","state":"accessDenied"},{"logicalArtifactId":"client-location","state":"absent"}], - "assertions": ["accessDenied is explicit coverage, not a failure diagnosis","the smallest next artifact is client-identity"] -} + "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 index 265c6fb56..6d9f364aa 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/incomplete/manifest.json @@ -1,13 +1,11 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, "scenario": "incomplete", - "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":"health-incomplete-ccmsetup-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-root-a-health-incomplete-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:20:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, - {"artifactId":"health-incomplete-evaluation-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-root-a-health-incomplete-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:20:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, - {"artifactId":"health-incomplete-identity-access-denied","designOnlyCatalog":{"entryId":"client-identity","groupMemberships":["client-identity"]},"role":"client","kind":"ccmLog","captureState":"accessDenied","encoding":null,"collectionLimit":null,"originalBasename":"ClientIDManagerStartup.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-incomplete-identity-access-denied","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:20:02Z","bytesCopied":0,"relativePath":null}, - {"artifactId":"health-incomplete-location-services-absent","designOnlyCatalog":{"entryId":"client-location-services-shared","groupMemberships":["client-content","client-location"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"LocationServices.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-incomplete-location-services-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:20:03Z","bytesCopied":0,"relativePath":null} + {"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 index d64eb5120..09b1d981c 100644 --- 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 @@ -1 +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 index e539a541a..c6ffe8fd9 100644 --- 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 @@ -1 +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 index 65e1dfcfc..8ba998507 100644 --- 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 @@ -1 +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 index 15846336f..475b821a1 100644 --- 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 @@ -1 +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 index 302b18260..c761bbfaa 100644 --- 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 @@ -1,3 +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 index 5fab01e39..a06b15fff 100644 --- 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 @@ -1,13 +1,175 @@ { - "contractState": "proposedPending318And319", - "scenario": "no-site-or-mp", - "workflow": "health", - "workflowDiagnosisExpected": true, - "lastSuccessfulPhase": "identity", - "findings": [ - {"findingId":"health-site-assignment-insufficient","class":"insufficientEvidence","phase":"siteAssignment","role":"client","confidence":"low","fixtureEvidence":[{"artifactId":"health-no-site-or-mp-location-services-current","entryId":"entry-000001","lineStart":1,"lineEnd":1}],"coverageGapArtifactIds":[],"nextArtifacts":[{"logicalArtifactId":"client-location","reason":"capture a complete site-assignment and MP-response sequence"}],"mustNotClaim":["client is unassigned","management point is unavailable","management point cause"]}, - {"findingId":"health-unkeyed-transport-symptom","class":"symptom","phase":"transport","role":"client","confidence":"low","fixtureEvidence":[{"artifactId":"health-no-site-or-mp-location-services-current","entryId":"entry-000002","lineStart":2,"lineEnd":2},{"artifactId":"health-no-site-or-mp-location-services-current","entryId":"entry-000003","lineStart":3,"lineEnd":3}],"coverageGapArtifactIds":[],"nextArtifacts":[],"mustNotClaim":["transport failure","management point failure","management point cause"]} - ], - "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"captured"},{"logicalArtifactId":"client-identity","state":"captured"},{"logicalArtifactId":"client-location","state":"captured"}], - "assertions": ["same-minute unkeyed network error is a symptom only","hostname-shaped unrelated text is not MP evidence"] -} + "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 index 2b751c290..3cc7da99c 100644 --- 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 @@ -1,13 +1,11 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, "scenario": "no-site-or-mp", - "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":"health-no-site-or-mp-ccmsetup-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-root-a-health-no-site-or-mp-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, - {"artifactId":"health-no-site-or-mp-evaluation-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-root-a-health-no-site-or-mp-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, - {"artifactId":"health-no-site-or-mp-identity-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-root-a-health-no-site-or-mp-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:02Z","bytesCopied":265,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, - {"artifactId":"health-no-site-or-mp-location-services-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-root-a-health-no-site-or-mp-location-services-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:30:04Z","bytesCopied":617,"relativePath":"evidence/client-location-services-shared/current/LocationServices.log"} + {"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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/expected.json index 276c5789b..b49646843 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/expected.json @@ -1,12 +1,104 @@ { - "contractState": "proposedPending318And319", - "scenario": "rotation-boundary", - "workflow": "health", - "workflowDiagnosisExpected": true, - "lastSuccessfulPhase": null, - "findings": [ - {"findingId":"health-setup-rotation-boundary","class":"insufficientEvidence","phase":"setup","role":"client","confidence":"low","fixtureEvidence":[],"coverageGapArtifactIds":["health-rotation-boundary-ccmsetup-current","health-rotation-boundary-ccmsetup-lo"],"nextArtifacts":[{"logicalArtifactId":"client-ccmsetup","reason":"capture a complete bootstrap logical record without a rotation boundary"}],"mustNotClaim":["setup success","setup failure","correlation key from split record"]} - ], - "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"absent"},{"logicalArtifactId":"client-identity","state":"absent"},{"logicalArtifactId":"client-location","state":"absent"}], - "assertions": ["each split fragment remains source-local","a record reconstructed across rotations cannot advance or fail the state machine"] -} + "productionAdmissionError": null, + "productionOutput": { + "findings": [ + { + "class": "insufficientEvidence", + "confidence": "low", + "correlationKeys": [], + "coverageGaps": [ + { + "artifactId": "health-rotation-boundary-ccmsetup-current", + "coverage": "capped", + "role": "client" + }, + { + "artifactId": "health-rotation-boundary-ccmsetup-lo", + "coverage": "capped", + "role": "client" + } + ], + "evidence": [], + "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 install phase cannot be evaluated because its exact source coverage is incomplete.", + "terminalEvidence": [], + "title": "Client health install evidence is incomplete" + } + ], + "hops": [], + "lastConfirmedSuccessfulPhase": null, + "lifecyclePhase": null, + "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-rotation-boundary-ccmsetup-current", + "coverage": "captured", + "fragmentComplete": false, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-rotation-boundary-ccmsetup-lo", + "coverage": "captured", + "fragmentComplete": false, + "logicalArtifactId": "client-ccmsetup", + "physical": true, + "rotation": { + "kind": "loUnderscore" + } + }, + { + "artifactId": "health-rotation-boundary-evaluation-absent", + "coverage": "absent", + "fragmentComplete": false, + "logicalArtifactId": "client-evaluation", + "physical": false, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-rotation-boundary-identity-absent", + "coverage": "absent", + "fragmentComplete": false, + "logicalArtifactId": "client-identity", + "physical": false, + "rotation": { + "kind": "current" + } + }, + { + "artifactId": "health-rotation-boundary-location-services-absent", + "coverage": "absent", + "fragmentComplete": false, + "logicalArtifactId": "client-location", + "physical": false, + "rotation": { + "kind": "current" + } + } + ] + }, + "scenario": "rotation-boundary" +} \ No newline at end of file diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/manifest.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/manifest.json index a72149147..b2c110867 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/rotation-boundary/manifest.json @@ -1,14 +1,12 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, "scenario": "rotation-boundary", - "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":"health-rotation-boundary-ccmsetup-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-root-a-health-rotation-boundary-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:00:01Z","bytesCopied":124,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, - {"artifactId":"health-rotation-boundary-ccmsetup-lo","designOnlyCatalog":{"entryId":"client-ccmsetup","groupMemberships":["client-ccmsetup"]},"role":"client","kind":"ccmLog","captureState":"captured","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"originalBasename":"ccmsetup.lo_","sanitizedSourcePath":"SYNTHETIC://root-a/ccmsetup/Logs/ccmsetup.lo_","pathFingerprint":"synthetic-root-a-health-rotation-boundary-ccmsetup-lo","rotation":{"kind":"lo","fragmentComplete":false},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:00:00Z","bytesCopied":118,"relativePath":"evidence/client-ccmsetup/lo/ccmsetup.lo_"}, - {"artifactId":"health-rotation-boundary-evaluation-absent","designOnlyCatalog":{"entryId":"client-evaluation","groupMemberships":["client-evaluation"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"CcmEval.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-rotation-boundary-evaluation-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:00:02Z","bytesCopied":0,"relativePath":null}, - {"artifactId":"health-rotation-boundary-identity-absent","designOnlyCatalog":{"entryId":"client-identity","groupMemberships":["client-identity"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"ClientIDManagerStartup.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-rotation-boundary-identity-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:00:03Z","bytesCopied":0,"relativePath":null}, - {"artifactId":"health-rotation-boundary-location-services-absent","designOnlyCatalog":{"entryId":"client-location-services-shared","groupMemberships":["client-content","client-location"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"LocationServices.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-rotation-boundary-location-services-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T02:00:04Z","bytesCopied":0,"relativePath":null} + {"artifactId":"health-rotation-boundary-ccmsetup-current","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ccmsetup.log","pathFingerprint":"synthetic-root-a-health-rotation-boundary-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T02:00:01Z","bytesCopied":124,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, + {"artifactId":"health-rotation-boundary-ccmsetup-lo","role":"client","captureState":"captured","encoding":"utf-8","originalBasename":"ccmsetup.lo_","pathFingerprint":"synthetic-root-a-health-rotation-boundary-ccmsetup-lo","rotation":{"kind":"lo","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T02:00:00Z","bytesCopied":118,"relativePath":"evidence/client-ccmsetup/lo/ccmsetup.lo_"}, + {"artifactId":"health-rotation-boundary-evaluation-absent","role":"client","captureState":"absent","encoding":null,"originalBasename":"CcmEval.log","pathFingerprint":"synthetic-candidate-health-rotation-boundary-evaluation-absent","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T02:00:02Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"health-rotation-boundary-identity-absent","role":"client","captureState":"absent","encoding":null,"originalBasename":"ClientIDManagerStartup.log","pathFingerprint":"synthetic-candidate-health-rotation-boundary-identity-absent","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T02:00:03Z","bytesCopied":0,"relativePath":null}, + {"artifactId":"health-rotation-boundary-location-services-absent","role":"client","captureState":"absent","encoding":null,"originalBasename":"LocationServices.log","pathFingerprint":"synthetic-candidate-health-rotation-boundary-location-services-absent","rotation":{"kind":"current","fragmentComplete":false},"sourceVersion":"5.00.9128.1000","capturedUtc":"2026-07-30T02:00:04Z","bytesCopied":0,"relativePath":null} ] } diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/evidence/client-ccmsetup/current/ccmsetup.log b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/evidence/client-ccmsetup/current/ccmsetup.log index 558471a01..b8461dd4b 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/evidence/client-ccmsetup/current/ccmsetup.log +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/setup-failure/evidence/client-ccmsetup/current/ccmsetup.log @@ -1 +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 index 46ce3cb41..1c3c2d913 100644 --- 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 @@ -1,12 +1,113 @@ { - "contractState": "proposedPending318And319", - "scenario": "setup-failure", - "workflow": "health", - "workflowDiagnosisExpected": true, - "lastSuccessfulPhase": null, - "findings": [ - {"findingId":"health-setup-terminal","class":"confirmedFailure","phase":"setup","role":"client","confidence":"high","fixtureEvidence":[{"artifactId":"health-setup-failure-ccmsetup-current","entryId":"entry-000001","lineStart":1,"lineEnd":1}],"coverageGapArtifactIds":[],"nextArtifacts":[],"mustNotClaim":["service failure","identity failure","management point cause"]} - ], - "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"absent"},{"logicalArtifactId":"client-identity","state":"absent"},{"logicalArtifactId":"client-location","state":"absent"}], - "assertions": ["terminal setup evidence is profile-validated","no later same-key bootstrap success exists"] -} + "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 index 12466890e..e202591c6 100644 --- 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 @@ -1,13 +1,11 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, "scenario": "setup-failure", - "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":"health-setup-failure-ccmsetup-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-root-a-health-setup-failure-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:10:00Z","bytesCopied":242,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, - {"artifactId":"health-setup-failure-evaluation-absent","designOnlyCatalog":{"entryId":"client-evaluation","groupMemberships":["client-evaluation"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"CcmEval.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-setup-failure-evaluation-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:10:01Z","bytesCopied":0,"relativePath":null}, - {"artifactId":"health-setup-failure-identity-absent","designOnlyCatalog":{"entryId":"client-identity","groupMemberships":["client-identity"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"ClientIDManagerStartup.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-setup-failure-identity-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:10:02Z","bytesCopied":0,"relativePath":null}, - {"artifactId":"health-setup-failure-location-services-absent","designOnlyCatalog":{"entryId":"client-location-services-shared","groupMemberships":["client-content","client-location"]},"role":"client","kind":"ccmLog","captureState":"absent","encoding":null,"collectionLimit":null,"originalBasename":"LocationServices.log","sanitizedSourcePath":null,"pathFingerprint":"synthetic-candidate-health-setup-failure-location-services-absent","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:10:03Z","bytesCopied":0,"relativePath":null} + {"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 index 998b67375..1bbe3efa4 100644 --- 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 @@ -1 +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 index f69d95d00..cf9eff344 100644 --- 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 @@ -1 +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 index 483d560a8..e372ed143 100644 --- 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 @@ -1 +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 index 63fb645d2..ce1b1d1de 100644 --- 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 @@ -1,4 +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 index bb767b063..bd6f4ee96 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/expected.json @@ -1,11 +1,180 @@ { - "contractState": "proposedPending318And319", - "scenario": "success", - "workflow": "health", - "workflowDiagnosisExpected": false, - "lastSuccessfulPhase": "transport", - "findings": [ - ], - "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"captured"},{"logicalArtifactId":"client-identity","state":"captured"},{"logicalArtifactId":"client-location","state":"captured"}], - "assertions": ["all phase transitions require source-local complete records","transport request and response share validated requestId and managementPointHost"] -} + "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 index 7042e514e..bb8f35b46 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/health/success/manifest.json @@ -1,13 +1,11 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, "scenario": "success", - "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":"health-success-ccmsetup-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-root-a-health-success-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, - {"artifactId":"health-success-evaluation-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-root-a-health-success-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, - {"artifactId":"health-success-identity-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-root-a-health-success-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:02Z","bytesCopied":265,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, - {"artifactId":"health-success-location-services-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-root-a-health-success-location-services-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:00:07Z","bytesCopied":954,"relativePath":"evidence/client-location-services-shared/current/LocationServices.log"} + {"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 index 7ee27ea67..1e612d58b 100644 --- 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 @@ -1 +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 index b17774230..d13500332 100644 --- 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 @@ -1 +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 index 2e96b38c0..f8d927c5d 100644 --- 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 @@ -1 +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 index fe19a4c51..06799fea2 100644 --- 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 @@ -1,4 +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 index 7c4e7f3f1..9cb9aa1c2 100644 --- 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 @@ -1,12 +1,221 @@ { - "contractState": "proposedPending318And319", - "scenario": "transport-failure", - "workflow": "health", - "workflowDiagnosisExpected": true, - "lastSuccessfulPhase": "managementPoint", - "findings": [ - {"findingId":"health-transport-terminal","class":"confirmedFailure","phase":"transport","role":"client","confidence":"high","fixtureEvidence":[{"artifactId":"health-transport-failure-location-services-current","entryId":"entry-000003","lineStart":3,"lineEnd":3},{"artifactId":"health-transport-failure-location-services-current","entryId":"entry-000004","lineStart":4,"lineEnd":4}],"coverageGapArtifactIds":[],"nextArtifacts":[],"mustNotClaim":["management point caused the failure","server-side failure","network root cause"]} - ], - "coverage": [{"logicalArtifactId":"client-ccmsetup","state":"captured"},{"logicalArtifactId":"client-evaluation","state":"captured"},{"logicalArtifactId":"client-identity","state":"captured"},{"logicalArtifactId":"client-location","state":"captured"}], - "assertions": ["terminal response shares validated requestId and managementPointHost with request","finding is limited to the client transport observation"] -} + "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 index d3d2dd356..b53bcffb3 100644 --- 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 @@ -1,13 +1,11 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, "scenario": "transport-failure", - "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":"health-transport-failure-ccmsetup-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-root-a-health-transport-failure-ccmsetup-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:40:00Z","bytesCopied":265,"relativePath":"evidence/client-ccmsetup/current/ccmsetup.log"}, - {"artifactId":"health-transport-failure-evaluation-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-root-a-health-transport-failure-evaluation-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:40:01Z","bytesCopied":253,"relativePath":"evidence/client-evaluation/current/CcmEval.log"}, - {"artifactId":"health-transport-failure-identity-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-root-a-health-transport-failure-identity-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:40:02Z","bytesCopied":265,"relativePath":"evidence/client-identity/current/ClientIDManagerStartup.log"}, - {"artifactId":"health-transport-failure-location-services-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-root-a-health-transport-failure-location-services-current","rotation":{"kind":"current","fragmentComplete":true},"sourceVersion":"5.00.TEST.0000","capturedUtc":"2026-07-30T01:40:07Z","bytesCopied":958,"relativePath":"evidence/client-location-services-shared/current/LocationServices.log"} + {"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/sccm_client_health.rs b/crates/cmtraceopen-parser/tests/sccm_client_health.rs new file mode 100644 index 000000000..46351c62c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_health.rs @@ -0,0 +1,576 @@ +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 { + 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 payload_bytes = (source_coverage == SccmCoverageState::Captured && fragment_complete) + .then(|| { + committed_bytes + .clone() + .ok_or_else(|| "complete capture has no relativePath".to_owned()) + }) + .transpose()?; + 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 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 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); + 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 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_health_fixture_contract.rs b/crates/cmtraceopen-parser/tests/sccm_client_health_fixture_contract.rs deleted file mode 100644 index 7aec93255..000000000 --- a/crates/cmtraceopen-parser/tests/sccm_client_health_fixture_contract.rs +++ /dev/null @@ -1,435 +0,0 @@ -use serde_json::Value; - -fn client_health_root() -> std::path::PathBuf { - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/client/health") -} - -fn client_health_manifests() -> Vec<(String, Value)> { - let mut scenario_dirs = std::fs::read_dir(client_health_root()) - .expect("client health fixture root is readable") - .map(|entry| { - entry - .expect("client health 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 is_exact_site_code(value: &str) -> bool { - value.len() == 3 - && value - .bytes() - .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) -} - -#[derive(Clone)] -struct ClientHealthSiteFactFile { - scenario: String, - relative_path: String, - site_facts: Vec<(usize, String)>, -} - -fn client_health_site_facts() -> Vec { - let root = client_health_root(); - let mut facts = Vec::new(); - - for entry in walkdir(&root) { - let is_evidence = entry - .components() - .any(|component| component.as_os_str() == std::ffi::OsStr::new("evidence")); - if !entry.is_file() || !is_evidence { - continue; - } - - let contents = std::fs::read_to_string(&entry).expect("evidence fixture is UTF-8"); - let site_facts = contents - .lines() - .enumerate() - .flat_map(|(line_index, line)| { - line.split("siteCode=").skip(1).map(move |suffix| { - ( - line_index + 1, - suffix - .chars() - .take_while(|character| character.is_ascii_alphanumeric()) - .collect::(), - ) - }) - }) - .collect::>(); - if site_facts.is_empty() { - continue; - } - - let relative_path = entry - .strip_prefix(&root) - .expect("health evidence is below the fixture root"); - let scenario = relative_path - .components() - .next() - .expect("health evidence has a scenario component") - .as_os_str() - .to_string_lossy() - .into_owned(); - facts.push(ClientHealthSiteFactFile { - scenario, - relative_path: relative_path.to_string_lossy().into_owned(), - site_facts, - }); - } - - facts.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); - facts -} - -fn client_health_site_contract_failures( - manifests: &[(String, Value)], - facts: &[ClientHealthSiteFactFile], -) -> Vec { - let mut failures = Vec::new(); - - if manifests.len() != 9 { - failures.push(format!( - "client health must contain exactly 9 manifests, got {}", - manifests.len() - )); - } - for (scenario, manifest) in manifests { - match manifest["bundle"]["siteCode"].as_str() { - Some("LAB") => {} - Some(site_code) => failures.push(format!( - "{scenario}: manifest siteCode must be LAB, got {site_code}" - )), - None => failures.push(format!( - "{scenario}: manifest siteCode must be LAB, got null" - )), - } - } - - let expected = [ - ( - "success", - "success/evidence/client-location-services-shared/current/LocationServices.log", - ), - ( - "transport-failure", - "transport-failure/evidence/client-location-services-shared/current/LocationServices.log", - ), - ]; - if facts.len() != expected.len() { - failures.push(format!( - "client health must contain siteCode facts in exactly 2 evidence files, got {}", - facts.len() - )); - } - - for (scenario, relative_path) in expected { - let matching_files = facts - .iter() - .filter(|fact| fact.relative_path == relative_path) - .collect::>(); - if matching_files.len() != 1 { - failures.push(format!( - "{relative_path}: expected exactly one siteCode evidence file, got {}", - matching_files.len() - )); - continue; - } - - let fact = matching_files[0]; - if fact.scenario != scenario { - failures.push(format!( - "{relative_path}: evidence scenario {} does not match {scenario}", - fact.scenario - )); - } - if fact.site_facts.len() != 2 { - failures.push(format!( - "{relative_path}: expected exactly 2 siteCode facts, got {}", - fact.site_facts.len() - )); - } - - let manifest_site_code = manifests - .iter() - .find(|(manifest_scenario, _)| manifest_scenario == scenario) - .and_then(|(_, manifest)| manifest["bundle"]["siteCode"].as_str()); - match manifest_site_code { - Some(manifest_site_code) => { - for (fact_index, (line_number, site_code)) in fact.site_facts.iter().enumerate() { - let expected_line = fact_index + 1; - if *line_number != expected_line { - failures.push(format!( - "{relative_path}: siteCode fact {} must be on line {expected_line}, got line {line_number}", - fact_index + 1 - )); - } - if site_code != manifest_site_code { - failures.push(format!( - "{relative_path}: evidence siteCode {site_code} does not match manifest siteCode {manifest_site_code}" - )); - } - } - } - None => failures.push(format!( - "{relative_path}: matching manifest siteCode is unavailable" - )), - } - } - - failures -} - -#[test] -fn client_health_site_contract_pins_exact_lab_evidence() { - let manifests = client_health_manifests(); - let facts = client_health_site_facts(); - assert!( - client_health_site_contract_failures(&manifests, &facts).is_empty(), - "canonical client health fixtures satisfy the exact site contract" - ); - - let mut wrong_manifest = manifests.clone(); - wrong_manifest[0].1["bundle"]["siteCode"] = Value::String("ABC".to_owned()); - assert!( - client_health_site_contract_failures(&wrong_manifest, &facts) - .iter() - .any(|failure| failure.contains("siteCode must be LAB")), - "a different valid three-character manifest code must fail closed" - ); - - let mut missing_fact = facts.clone(); - missing_fact[0].site_facts.pop(); - assert!( - client_health_site_contract_failures(&manifests, &missing_fact) - .iter() - .any(|failure| failure.contains("exactly 2 siteCode facts")), - "a missing evidence fact must fail closed" - ); - - let mut mismatched_fact = facts.clone(); - mismatched_fact[0].site_facts[0].1 = "XYZ".to_owned(); - assert!( - client_health_site_contract_failures(&manifests, &mismatched_fact) - .iter() - .any(|failure| failure.contains("does not match manifest siteCode LAB")), - "a different valid evidence code must fail closed" - ); - - let mut moved_fact = facts.clone(); - moved_fact[0].site_facts[0].0 = 3; - assert!( - client_health_site_contract_failures(&manifests, &moved_fact) - .iter() - .any(|failure| failure.contains("must be on line 1")), - "the four evidence facts must remain on the contracted lines" - ); -} - -fn client_health_rotation_contract_failures(manifest: &Value) -> Vec { - const EXPECTED_RELATIVE_PATH: &str = "evidence/client-ccmsetup/lo/ccmsetup.lo_"; - const EXPECTED_SANITIZED_PATH: &str = "SYNTHETIC://root-a/ccmsetup/Logs/ccmsetup.lo_"; - - let mut failures = Vec::new(); - let Some(artifacts) = manifest["artifacts"].as_array() else { - return vec!["rotation-boundary: artifacts must be an array".to_owned()]; - }; - let rollovers = artifacts - .iter() - .filter(|artifact| artifact["rotation"]["kind"] == "lo") - .collect::>(); - if rollovers.len() != 1 { - return vec![format!( - "rotation-boundary: expected exactly one .lo_ artifact, got {}", - rollovers.len() - )]; - } - let rollover = rollovers[0]; - - match rollover["originalBasename"].as_str() { - Some("ccmsetup.lo_") => {} - Some(basename) => failures.push(format!( - "rotation-boundary: originalBasename must equal ccmsetup.lo_, got {basename}" - )), - None => failures.push( - "rotation-boundary: originalBasename must equal ccmsetup.lo_, got null".to_owned(), - ), - } - - let relative_path = rollover["relativePath"].as_str(); - match relative_path { - Some(EXPECTED_RELATIVE_PATH) => {} - Some(relative_path) => failures.push(format!( - "rotation-boundary: relativePath must equal {EXPECTED_RELATIVE_PATH}, got {relative_path}" - )), - None => failures.push(format!( - "rotation-boundary: relativePath must equal {EXPECTED_RELATIVE_PATH}, got null" - )), - } - - match rollover["sanitizedSourcePath"].as_str() { - Some(EXPECTED_SANITIZED_PATH) => {} - Some(sanitized_path) => failures.push(format!( - "rotation-boundary: sanitizedSourcePath must equal {EXPECTED_SANITIZED_PATH}, got {sanitized_path}" - )), - None => failures.push(format!( - "rotation-boundary: sanitizedSourcePath must equal {EXPECTED_SANITIZED_PATH}, got null" - )), - } - - if relative_path == Some(EXPECTED_RELATIVE_PATH) { - let fixture_path = client_health_root() - .join("rotation-boundary") - .join(EXPECTED_RELATIVE_PATH); - if !fixture_path.is_file() { - failures.push(format!( - "rotation-boundary: manifest path 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(); - match rollover["bytesCopied"].as_u64() { - Some(bytes_copied) if bytes_copied == actual_bytes => {} - Some(bytes_copied) => failures.push(format!( - "rotation-boundary: bytesCopied {bytes_copied} does not match fixture length {actual_bytes}" - )), - None => failures.push( - "rotation-boundary: captured rollover must record bytesCopied".to_owned(), - ), - } - } - } - - failures -} - -#[test] -fn client_health_rotation_contract_pins_full_paths() { - let manifests = client_health_manifests(); - let rotations = manifests - .iter() - .find(|(scenario, _)| scenario == "rotation-boundary") - .map(|(_, manifest)| manifest) - .expect("client health has a rotation-boundary scenario"); - assert!( - client_health_rotation_contract_failures(rotations).is_empty(), - "canonical client health rotation fixture satisfies the exact path contract" - ); - - let mut wrong_relative_path = rotations.clone(); - let rollover = wrong_relative_path["artifacts"] - .as_array_mut() - .expect("rotation artifacts are an array") - .iter_mut() - .find(|artifact| artifact["rotation"]["kind"] == "lo") - .expect("rotation corpus has a .lo_ artifact"); - rollover["relativePath"] = Value::String("evidence/other/ccmsetup.lo_".to_owned()); - assert!( - client_health_rotation_contract_failures(&wrong_relative_path) - .iter() - .any(|failure| failure.contains("relativePath must equal")), - "a filename-matching but layout-changing relative path must fail closed" - ); - - let mut wrong_provenance = rotations.clone(); - let rollover = wrong_provenance["artifacts"] - .as_array_mut() - .expect("rotation artifacts are an array") - .iter_mut() - .find(|artifact| artifact["rotation"]["kind"] == "lo") - .expect("rotation corpus has a .lo_ artifact"); - rollover["sanitizedSourcePath"] = - Value::String("SYNTHETIC://different-root/ccmsetup.lo_".to_owned()); - assert!( - client_health_rotation_contract_failures(&wrong_provenance) - .iter() - .any(|failure| failure.contains("sanitizedSourcePath must equal")), - "a filename-matching but provenance-changing source path must fail closed" - ); - - let mut duplicate_rollover = rotations.clone(); - let artifacts = duplicate_rollover["artifacts"] - .as_array_mut() - .expect("rotation artifacts are an array"); - let duplicate = artifacts - .iter() - .find(|artifact| artifact["rotation"]["kind"] == "lo") - .expect("rotation corpus has a .lo_ artifact") - .clone(); - artifacts.push(duplicate); - assert!( - client_health_rotation_contract_failures(&duplicate_rollover) - .iter() - .any(|failure| failure.contains("exactly one .lo_ artifact")), - "a duplicate .lo_ artifact must fail closed" - ); -} - -#[test] -fn client_health_uses_canonical_site_and_rotation_contracts() { - let manifests = client_health_manifests(); - assert_eq!(manifests.len(), 9, "client health scenario matrix changed"); - - let mut failures = Vec::new(); - for (scenario, manifest) in &manifests { - let site_code = manifest["bundle"]["siteCode"] - .as_str() - .expect("client health bundle has a site code"); - if !is_exact_site_code(site_code) { - failures.push(format!( - "{scenario}: siteCode must match ^[A-Z0-9]{{3}}$, got {site_code}" - )); - } - } - failures.extend(client_health_site_contract_failures( - &manifests, - &client_health_site_facts(), - )); - - let rotations = manifests - .iter() - .find(|(scenario, _)| scenario == "rotation-boundary") - .map(|(_, manifest)| manifest) - .expect("client health has a rotation-boundary scenario"); - failures.extend(client_health_rotation_contract_failures(rotations)); - - assert!(failures.is_empty(), "{}", failures.join("\n")); -} - -fn walkdir(root: &std::path::Path) -> Vec { - 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 -} From 94ca82897e1671bcd41c88236ab00c80338a57fd Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 04:05:51 -0400 Subject: [PATCH 393/422] fix(sccm): preserve client health chain authority --- .../src/sccm/client/health.rs | 22 +- .../cross-client-management-point.json | 207 ++++++++++++++++++ .../service-before-install.json | 111 ++++++++++ .../tests/sccm_client_health.rs | 88 +++++++- 4 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/cross-client-management-point.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/service-before-install.json diff --git a/crates/cmtraceopen-parser/src/sccm/client/health.rs b/crates/cmtraceopen-parser/src/sccm/client/health.rs index 52b3a96ef..7fddbf73e 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/health.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/health.rs @@ -133,6 +133,7 @@ struct ChainKeys { client_guid: Option, site_code: Option, management_point_host: Option, + last_utc_millis: Option, } #[derive(Debug)] @@ -175,7 +176,7 @@ pub fn analyze_client_health( match lifecycle { Resolution::Succeeded(fact) => { - chain.client_guid = fact.keys.client_guid.clone(); + advance_chain(&mut chain, fact); last_success = Some(fact.phase); hops.push(hop(fact.phase, SccmClientHealthHopState::Succeeded, [fact])); } @@ -585,6 +586,12 @@ fn resolve_candidates<'a>( } 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 @@ -593,15 +600,25 @@ fn fact_matches_chain(fact: &HealthFact, chain: &ChainKeys) -> bool { | SccmClientHealthPhase::Authentication | SccmClientHealthPhase::Assignment | SccmClientHealthPhase::Boundary => fact.keys.client_guid == chain.client_guid, - SccmClientHealthPhase::ManagementPointLocation => fact.keys.site_code == chain.site_code, + 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(); @@ -612,6 +629,7 @@ fn advance_chain(chain: &mut ChainKeys, fact: &HealthFact) { 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 { 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/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/sccm_client_health.rs b/crates/cmtraceopen-parser/tests/sccm_client_health.rs index 46351c62c..7e36507f2 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_health.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_health.rs @@ -70,6 +70,13 @@ fn rotation(value: &str) -> SccmRotation { } 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!( @@ -156,13 +163,17 @@ fn admit_fixture(scenario: &str) -> Result { ), "{scenario}: exact committed fixture byte count" ); - let payload_bytes = (source_coverage == SccmCoverageState::Captured && fragment_complete) + 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(), @@ -218,6 +229,29 @@ fn analyze_fixture(scenario: &str) -> SccmClientHealthAnalysis { .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", + ) + } + _ => 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() @@ -375,6 +409,58 @@ fn success_fixture_confirms_every_post_install_hop() { ); } +#[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 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 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"] { + 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 terminal_and_incomplete_stops_are_conservative_and_request_one_exact_artifact() { for (scenario, phase, class, last_success, logical_id) in [ From dfc002617d85af15fbd23d37aded124034f77083 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 04:25:15 -0400 Subject: [PATCH 394/422] fix(sccm): resolve newest client health attempt --- .../src/sccm/client/health.rs | 48 ++--- .../repair-retry-after-install-success.json | 99 ++++++++++ .../repair-retry-terminal-after-pending.json | 176 ++++++++++++++++++ .../service-retry-after-success.json | 111 +++++++++++ .../tests/sccm_client_health.rs | 94 +++++++++- 5 files changed, 506 insertions(+), 22 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/repair-retry-after-install-success.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/repair-retry-terminal-after-pending.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/health-regression-oracles/service-retry-after-success.json diff --git a/crates/cmtraceopen-parser/src/sccm/client/health.rs b/crates/cmtraceopen-parser/src/sccm/client/health.rs index 7fddbf73e..c7ea9f059 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/health.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/health.rs @@ -529,43 +529,49 @@ fn resolve_candidates<'a>( if candidates.is_empty() { return Resolution::Missing; } - let terminal = candidates - .iter() - .copied() - .filter(|fact| fact.terminal) - .collect::>(); - if terminal.is_empty() { - return Resolution::Pending(candidates); - } if phase.is_none() - && terminal + && candidates .iter() - .map(|fact| fact.phase) + .filter_map(|fact| fact.keys.client_guid.as_deref()) .collect::>() .len() != 1 { - return Resolution::Contradictory(terminal); + 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() - && terminal + && newest .iter() - .filter_map(|fact| fact.keys.client_guid.as_deref()) + .map(|fact| fact.phase) .collect::>() .len() != 1 { - return Resolution::Contradictory(terminal); + return Resolution::Contradictory(newest); } - let last = *terminal + let newest_disposition = newest[0].disposition; + if newest .iter() - .max_by_key(|fact| (fact.utc_millis, reference_identity(&fact.reference))) - .expect("terminal set is nonempty"); - if terminal - .iter() - .any(|fact| fact.utc_millis == last.utc_millis && fact.disposition != last.disposition) + .any(|fact| fact.disposition != newest_disposition) { - return Resolution::Contradictory(terminal); + 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| { 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-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/sccm_client_health.rs b/crates/cmtraceopen-parser/tests/sccm_client_health.rs index 7e36507f2..f9427ce80 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_health.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_health.rs @@ -242,6 +242,20 @@ fn analyze_success_regression(name: &str) -> (SccmClientHealthAnalysis, BTreeMap "Phase=managementPointLocation Disposition=succeeded Terminal=true ClientGuid=22222222-2222-2222-2222-222222222222 SiteCode=LAB", ) } + ("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(); @@ -431,6 +445,78 @@ fn post_lifecycle_phases_require_strict_monotonic_time() { ); } +#[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"); @@ -453,7 +539,13 @@ fn management_point_identity_cannot_cross_clients_on_a_shared_site() { 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"] { + 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")); From 8c3a48a0acaf82e5e31d89d4035ca9bbc0948261 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 04:38:58 -0400 Subject: [PATCH 395/422] fix(sccm): reject equal-time health key conflicts --- .../src/sccm/client/health.rs | 35 ++++++++ .../tests/sccm_client_health.rs | 88 +++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/crates/cmtraceopen-parser/src/sccm/client/health.rs b/crates/cmtraceopen-parser/src/sccm/client/health.rs index c7ea9f059..7f7397be7 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/health.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/health.rs @@ -559,6 +559,13 @@ fn resolve_candidates<'a>( { 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() @@ -591,6 +598,34 @@ fn resolve_candidates<'a>( } } +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 diff --git a/crates/cmtraceopen-parser/tests/sccm_client_health.rs b/crates/cmtraceopen-parser/tests/sccm_client_health.rs index f9427ce80..26f836845 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_health.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_health.rs @@ -242,6 +242,18 @@ fn analyze_success_regression(name: &str) -> (SccmClientHealthAnalysis, BTreeMap "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" + ), ("service-retry-after-success", "health-success-evaluation-current") => format!( "{content}\n" ), @@ -535,6 +547,60 @@ fn management_point_identity_cannot_cross_clients_on_a_shared_site() { .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 sealed_admission_regressions_match_exact_full_output_oracles() { let oracle_root = Path::new(env!("CARGO_MANIFEST_DIR")) @@ -553,6 +619,28 @@ fn sealed_admission_regressions_match_exact_full_output_oracles() { } } +#[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", + ), + ] { + 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 [ From 320267eea8441f309ba33b96953862005247ffc4 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 04:50:56 -0400 Subject: [PATCH 396/422] fix(sccm): expose equal-time health tuple conflicts --- .../src/sccm/client/health.rs | 15 ++- .../tests/sccm_client_health.rs | 112 ++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/health.rs b/crates/cmtraceopen-parser/src/sccm/client/health.rs index 7f7397be7..4e89279bc 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/health.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/health.rs @@ -510,10 +510,23 @@ fn resolve_phase<'a>( phase: SccmClientHealthPhase, chain: &ChainKeys, ) -> Resolution<'a> { - let candidates = facts + 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), diff --git a/crates/cmtraceopen-parser/tests/sccm_client_health.rs b/crates/cmtraceopen-parser/tests/sccm_client_health.rs index 26f836845..7689de6cc 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_health.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_health.rs @@ -254,6 +254,36 @@ fn analyze_success_regression(name: &str) -> (SccmClientHealthAnalysis, BTreeMap ) => 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" ), @@ -601,6 +631,68 @@ fn equal_time_transport_requests_are_contradictory() { ); } +#[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")) @@ -630,6 +722,26 @@ fn equal_time_correlation_regressions_match_exact_full_output_oracles() { "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); From d8a2589778a554d4a6a1127387a4f2e9a5676eea Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 01:32:35 -0400 Subject: [PATCH 397/422] feat(sccm): analyze sealed task sequence evidence --- .../cmtraceopen-parser/src/sccm/client/mod.rs | 2 + .../src/sccm/client/task_sequence.rs | 675 ++++++++++++++++++ .../tests/sccm_client_task_sequence.rs | 455 ++++++++++++ 3 files changed, 1132 insertions(+) create mode 100644 crates/cmtraceopen-parser/src/sccm/client/task_sequence.rs create mode 100644 crates/cmtraceopen-parser/tests/sccm_client_task_sequence.rs diff --git a/crates/cmtraceopen-parser/src/sccm/client/mod.rs b/crates/cmtraceopen-parser/src/sccm/client/mod.rs index 2a574effd..d276dcd6d 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -9,6 +9,7 @@ mod health; mod intake; mod inventory; mod policy; +mod task_sequence; mod updates; #[cfg(test)] @@ -30,4 +31,5 @@ pub use inventory::{ SccmClientExtendedTransaction, SccmClientExtendedWorkflow, }; pub use policy::*; +pub use task_sequence::*; pub use updates::*; 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..1726b3784 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/task_sequence.rs @@ -0,0 +1,675 @@ +//! 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 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, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmTaskSequenceAnalysis { + pub transactions: 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, +} + +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, + }); + continue; + }; + let Some(observation) = + extract_observation(record, source.path_class, &source.rotation, profile) + else { + unlinked.push(UnlinkedObservation { + evidence: record.reference.clone(), + path_class: source.path_class, + }); + 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, + }); + } + } + + let mut transactions = groups + .into_values() + .enumerate() + .map(|(index, observations)| reduce_group(index + 1, observations)) + .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)); + + let mut findings = transactions + .iter() + .filter(|transaction| transaction.classification != SccmTaskSequenceClassification::Success) + .map(finding_for_transaction) + .collect::>(); + unlinked.sort_by(|left, right| compare_evidence_refs(&left.evidence, &right.evidence)); + findings.extend( + unlinked + .into_iter() + .enumerate() + .map(|(index, observation)| finding_for_unlinked(index + 1, observation)), + ); + if !coverage_gaps.is_empty() { + findings.push(finding_for_coverage(&coverage_gaps)); + } + findings.sort_by(|left, right| left.finding_id.cmp(&right.finding_id)); + + Ok(SccmTaskSequenceAnalysis { + transactions, + 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 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> { + message.split_ascii_whitespace().find_map(|token| { + let (candidate_label, value) = token.split_once('=')?; + candidate_label + .eq_ignore_ascii_case(label) + .then_some(value) + .filter(|value| !value.is_empty()) + }) +} + +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(index: usize, mut observations: Vec) -> SccmTaskSequenceTransaction { + observations.sort_by(compare_observations); + 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(); + let phases_are_monotonic = observations + .windows(2) + .all(|pair| pair[0].phase <= pair[1].phase); + let ordering_is_safe = ordering_is_normalized && timestamps_are_unique && phases_are_monotonic; + let final_observation = observations + .last() + .expect("an execution group contains at least one observation"); + let (classification, confidence) = classify_transaction(final_observation, ordering_is_safe); + let evidence = observations + .iter() + .map(|observation| observation.evidence.clone()) + .collect::>(); + let terminal_evidence = final_observation + .terminal + .then(|| final_observation.evidence.clone()); + + SccmTaskSequenceTransaction { + transaction_id: format!("task-sequence-{index:04}"), + 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: final_observation.phase, + state: final_observation.state, + last_successful_phase: last_successful_phase(final_observation), + classification, + confidence, + ordering_state: if ordering_is_safe { + SccmTaskSequenceOrderingState::NormalizedUtc + } else if ordering_is_normalized { + SccmTaskSequenceOrderingState::Ambiguous + } else { + task_sequence_ordering_state(&final_observation.ordering_state) + }, + terminal_evidence, + next_evidence: next_evidence(final_observation.phase, 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(|| left.phase.cmp(&right.phase)) + .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( + phase: SccmTaskSequencePhase, + classification: SccmTaskSequenceClassification, +) -> Option { + if matches!( + classification, + SccmTaskSequenceClassification::Success | SccmTaskSequenceClassification::ConfirmedFailure + ) { + return None; + } + let (path_class, reason) = match phase { + 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: format!("{}-finding", transaction.transaction_id), + transaction_id: Some(transaction.transaction_id.clone()), + classification: transaction.classification, + phase: Some(transaction.phase), + confidence: transaction.confidence, + evidence: transaction.evidence.clone(), + coverage_gaps: Vec::new(), + next_evidence: transaction.next_evidence.clone(), + } +} + +fn finding_for_unlinked(index: usize, observation: UnlinkedObservation) -> SccmTaskSequenceFinding { + SccmTaskSequenceFinding { + finding_id: format!("task-sequence-unlinked-{index:04}"), + transaction_id: None, + classification: SccmTaskSequenceClassification::InsufficientEvidence, + phase: None, + confidence: SccmTaskSequenceConfidence::Low, + evidence: vec![observation.evidence], + coverage_gaps: Vec::new(), + next_evidence: Some(SccmTaskSequenceNextEvidence { + logical_artifact_id: TASK_SEQUENCE_LOGICAL_ARTIFACT_ID.to_owned(), + path_class: observation.path_class, + reason: "Collect a complete record under a reviewed Task Sequence profile.".to_owned(), + }), + } +} + +fn finding_for_coverage(coverage_gaps: &[SccmTaskSequenceCoverageGap]) -> SccmTaskSequenceFinding { + let path_class = coverage_gaps + .first() + .map(|gap| gap.path_class) + .unwrap_or(SccmTaskSequencePathClass::Unknown); + SccmTaskSequenceFinding { + finding_id: "task-sequence-coverage-0001".to_owned(), + transaction_id: None, + classification: SccmTaskSequenceClassification::InsufficientEvidence, + phase: None, + confidence: SccmTaskSequenceConfidence::Low, + evidence: Vec::new(), + coverage_gaps: coverage_gaps.to_vec(), + 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(), + }), + } +} 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..1e1c8c8a2 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence.rs @@ -0,0 +1,455 @@ +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, 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 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 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" && fragment_complete == Some(true)); + 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() +} + +#[test] +fn every_committed_scenario_runs_through_the_exported_production_reducer() { + for scenario in SCENARIOS { + let expected = read_json(&fixture_root(scenario).join("expected.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 (actual_transaction, expected_transaction) in + actual_transactions.iter().zip(expected_transactions) + { + for field in ["phase", "state", "classification"] { + assert_eq!( + actual_transaction[field], expected_transaction[field], + "{scenario}: transaction {field}" + ); + } + 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["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" + ); + } + + assert_eq!( + actual["findings"].as_array().map(Vec::len), + expected["findings"].as_array().map(Vec::len), + "{scenario}: finding count" + ); + } +} + +#[test] +fn exported_analysis_redacts_join_identity_paths_and_native_acceptance() { + let admitted = admitted_scenario("relocated-fragments"); + let wire = serde_json::to_string( + &analyze_client_task_sequence(&admitted).expect("sealed Task Sequence analysis succeeds"), + ) + .expect("Task Sequence analysis serializes"); + + assert!(!wire.contains("72400000-0000-0000-0000-000000000006")); + assert!(!wire.contains("LAB00324")); + assert!(!wire.contains("LAB20306")); + 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 + ); +} + +#[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 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 + ); +} From 2c2db833f458e15c29f4a397ab8ff00fadb58bdc Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 01:54:47 -0400 Subject: [PATCH 398/422] fix(sccm): harden task sequence evidence reduction --- crates/cmtraceopen-parser/src/sccm/catalog.rs | 4 + .../src/sccm/client/admission.rs | 147 ++++- .../src/sccm/client/intake.rs | 96 ++- .../cmtraceopen-parser/src/sccm/client/mod.rs | 3 + .../src/sccm/client/task_sequence.rs | 423 +++++++++++-- .../tests/sccm_client_task_sequence.rs | 554 +++++++++++++++++- 6 files changed, 1133 insertions(+), 94 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs index 7e7a69d98..92317b4f0 100644 --- a/crates/cmtraceopen-parser/src/sccm/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -263,6 +263,10 @@ const CLIENT_SOURCE_MEMBERSHIPS: &[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"], diff --git a/crates/cmtraceopen-parser/src/sccm/client/admission.rs b/crates/cmtraceopen-parser/src/sccm/client/admission.rs index ade237ec3..99d58fb16 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/admission.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/admission.rs @@ -31,9 +31,13 @@ use crate::sccm::{ use super::{ assess_client_intake, - intake::{is_safe_artifact_id, is_supported_encoding, source_matches_group}, + intake::{ + is_safe_artifact_id, is_supported_encoding, source_matches_group, + task_sequence_path_class_for_relative_path, + }, SccmClientIntakeAssessment, SccmClientIntakeBundle, SccmClientIntakeError, - SccmClientIntakeFragment, MAX_SCCM_CLIENT_INTAKE_ARTIFACTS, + 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 @@ -96,6 +100,7 @@ pub struct SccmClientAdmittedEvidence { unavailable_source_basenames: BTreeSet, admitted_source_groups: BTreeSet, profiles_by_artifact: BTreeMap, + task_sequence_sources: BTreeMap, integrity_seal: String, } @@ -108,6 +113,29 @@ pub(crate) struct SccmClientAdmittedSourceArtifact { 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. @@ -153,6 +181,18 @@ impl SccmClientAdmittedEvidence { 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, @@ -238,6 +278,7 @@ impl SccmClientAdmittedEvidence { 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) @@ -445,12 +486,21 @@ pub fn admit_client_evidence( if !classified.supported_for_diagnosis || !classified.uses_ccm_records { continue; } - if fragment.coverage != SccmCoverageState::Captured - || fragment.fragment_complete != Some(true) - { + 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()); @@ -484,6 +534,30 @@ pub fn admit_client_evidence( }) .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())) @@ -517,11 +591,26 @@ pub fn admit_client_evidence( let fragment = *fragment; validate_payload(payload, fragment)?; - let profile = SccmExtractionProfile::for_artifact_family( - fragment.configmgr_version.as_deref(), - family, - ); + 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); @@ -580,6 +669,7 @@ pub fn admit_client_evidence( 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 { @@ -591,6 +681,7 @@ pub fn admit_client_evidence( unavailable_source_basenames, admitted_source_groups, profiles_by_artifact, + task_sequence_sources, integrity_seal, }) } @@ -611,6 +702,41 @@ fn has_consistent_timestamp_provenance(evidence: &SccmEvidence) -> bool { } } +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> { @@ -807,6 +933,7 @@ struct IntegrityProjection<'a> { admitted_source_groups: &'a BTreeSet, profile_assignments: &'a BTreeMap<&'a str, usize>, profiles: &'a [&'a SccmExtractionProfile], + task_sequence_sources: &'a BTreeMap, } struct BoundedIntegrityWriter { @@ -863,6 +990,7 @@ struct IntegrityAuthority<'a> { unavailable_source_basenames: &'a BTreeSet, admitted_source_groups: &'a BTreeSet, profiles_by_artifact: &'a BTreeMap, + task_sequence_sources: &'a BTreeMap, } fn compute_integrity_seal( @@ -922,6 +1050,7 @@ fn compute_integrity_seal( 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() { diff --git a/crates/cmtraceopen-parser/src/sccm/client/intake.rs b/crates/cmtraceopen-parser/src/sccm/client/intake.rs index 784f7f731..851854833 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/intake.rs @@ -142,12 +142,24 @@ 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 { @@ -188,15 +200,16 @@ pub struct SccmClientIntakeArtifact { /// `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, - /// complete `Captured` fragment. This is optional so legacy intake stays - /// assessment-compatible, but admission requires it together with - /// `content_sha256` before caller-supplied bytes can become evidence. + /// 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, incomplete, or - /// unsupported declarations. + /// `declared_byte_length` and is forbidden on noncaptured or unsupported + /// declarations. #[serde(default, skip_serializing_if = "Option::is_none")] pub content_sha256: Option, } @@ -875,6 +888,7 @@ struct ClientSourceGroupSpec { 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]; @@ -950,6 +964,11 @@ const CLIENT_SOURCE_GROUPS: &[ClientSourceGroupSpec] = &[ 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, @@ -1578,9 +1597,16 @@ fn validate_content_binding( _ => 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 + || !(fragment_complete || is_recognized_task_sequence_fragment) || matching_groups(&source.artifact.display_name, &source.artifact.rotation).is_empty()) { return Err(SccmClientIntakeError::InvalidContentBinding); @@ -1924,6 +1950,10 @@ fn is_safe_relative_path(value: &str, display_name: &str, rotation: &SccmRotatio 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), @@ -1944,6 +1974,58 @@ fn is_safe_relative_path(value: &str, display_name: &str, rotation: &SccmRotatio && 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, diff --git a/crates/cmtraceopen-parser/src/sccm/client/mod.rs b/crates/cmtraceopen-parser/src/sccm/client/mod.rs index d276dcd6d..efd8ecb60 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -3,6 +3,9 @@ //! 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; diff --git a/crates/cmtraceopen-parser/src/sccm/client/task_sequence.rs b/crates/cmtraceopen-parser/src/sccm/client/task_sequence.rs index 1726b3784..fcc80732f 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/task_sequence.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/task_sequence.rs @@ -10,6 +10,7 @@ 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, @@ -140,15 +141,48 @@ pub struct SccmTaskSequenceFinding { pub classification: SccmTaskSequenceClassification, pub phase: Option, pub confidence: SccmTaskSequenceConfidence, - pub evidence: Vec, + 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, } @@ -178,6 +212,11 @@ struct Observation { 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( @@ -198,16 +237,32 @@ pub fn analyze_client_task_sequence( 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(UnlinkedObservation { - evidence: record.reference.clone(), - path_class: source.path_class, - }); + 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; }; @@ -228,15 +283,16 @@ pub fn analyze_client_task_sequence( 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() - .enumerate() - .map(|(index, observations)| reduce_group(index + 1, observations)) - .collect::>(); + 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 @@ -264,25 +320,47 @@ pub fn analyze_client_task_sequence( } 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::>(); - unlinked.sort_by(|left, right| compare_evidence_refs(&left.evidence, &right.evidence)); + let uncovered_source_observations = source_local_observations + .iter() + .filter(|observation| { + !coverage_gaps + .iter() + .any(|gap| gap.artifact_id == observation.artifact_id) + }) + .collect::>(); findings.extend( - unlinked - .into_iter() - .enumerate() - .map(|(index, observation)| finding_for_unlinked(index + 1, observation)), + uncovered_source_observations + .iter() + .map(|observation| finding_for_source_locals(&[*observation])), ); if !coverage_gaps.is_empty() { - findings.push(finding_for_coverage(&coverage_gaps)); + 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, }) @@ -358,6 +436,115 @@ fn extract_observation( }) } +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) @@ -366,13 +553,12 @@ fn is_reviewed_profile(profile: &SccmExtractionProfile) -> bool { } fn capture_field<'a>(message: &'a str, label: &str) -> Option<&'a str> { - message.split_ascii_whitespace().find_map(|token| { + let mut values = message.split_ascii_whitespace().filter_map(|token| { let (candidate_label, value) = token.split_once('=')?; - candidate_label - .eq_ignore_ascii_case(label) - .then_some(value) - .filter(|value| !value.is_empty()) - }) + (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 { @@ -443,8 +629,12 @@ fn is_opaque_token(value: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) } -fn reduce_group(index: usize, mut observations: Vec) -> SccmTaskSequenceTransaction { - observations.sort_by(compare_observations); +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() @@ -455,24 +645,65 @@ fn reduce_group(index: usize, mut observations: Vec) -> SccmTaskSeq .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); - let ordering_is_safe = ordering_is_normalized && timestamps_are_unique && phases_are_monotonic; - let final_observation = observations - .last() - .expect("an execution group contains at least one observation"); - let (classification, confidence) = classify_transaction(final_observation, ordering_is_safe); + // 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 = final_observation - .terminal - .then(|| final_observation.evidence.clone()); + 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: format!("task-sequence-{index:04}"), + 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(), @@ -486,20 +717,14 @@ fn reduce_group(index: usize, mut observations: Vec) -> SccmTaskSeq rotation: observation.rotation.clone(), }) .collect(), - phase: final_observation.phase, - state: final_observation.state, - last_successful_phase: last_successful_phase(final_observation), + phase: representative.phase, + state: representative.state, + last_successful_phase: last_successful_phase(representative), classification, confidence, - ordering_state: if ordering_is_safe { - SccmTaskSequenceOrderingState::NormalizedUtc - } else if ordering_is_normalized { - SccmTaskSequenceOrderingState::Ambiguous - } else { - task_sequence_ordering_state(&final_observation.ordering_state) - }, + ordering_state, terminal_evidence, - next_evidence: next_evidence(final_observation.phase, classification), + next_evidence: next_evidence(representative, classification), } } @@ -517,7 +742,6 @@ fn task_sequence_ordering_state( fn compare_observations(left: &Observation, right: &Observation) -> Ordering { left.utc_millis .cmp(&right.utc_millis) - .then_with(|| left.phase.cmp(&right.phase)) .then_with(|| compare_evidence_refs(&left.evidence, &right.evidence)) } @@ -588,7 +812,7 @@ fn last_successful_phase(observation: &Observation) -> SccmTaskSequencePhase { } fn next_evidence( - phase: SccmTaskSequencePhase, + observation: &Observation, classification: SccmTaskSequenceClassification, ) -> Option { if matches!( @@ -597,7 +821,15 @@ fn next_evidence( ) { return None; } - let (path_class, reason) = match phase { + 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.", @@ -624,47 +856,96 @@ fn next_evidence( fn finding_for_transaction(transaction: &SccmTaskSequenceTransaction) -> SccmTaskSequenceFinding { SccmTaskSequenceFinding { - finding_id: format!("{}-finding", transaction.transaction_id), + 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.clone(), + evidence: transaction.evidence.iter().map(evidence_citation).collect(), coverage_gaps: Vec::new(), - next_evidence: transaction.next_evidence.clone(), + next_evidence: None, } } -fn finding_for_unlinked(index: usize, observation: UnlinkedObservation) -> SccmTaskSequenceFinding { +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: format!("task-sequence-unlinked-{index:04}"), + 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: vec![observation.evidence], + evidence, coverage_gaps: Vec::new(), - next_evidence: Some(SccmTaskSequenceNextEvidence { - logical_artifact_id: TASK_SEQUENCE_LOGICAL_ARTIFACT_ID.to_owned(), - path_class: observation.path_class, - reason: "Collect a complete record under a reviewed Task Sequence profile.".to_owned(), - }), + 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]) -> SccmTaskSequenceFinding { +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: "task-sequence-coverage-0001".to_owned(), + 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: Vec::new(), - coverage_gaps: coverage_gaps.to_vec(), + evidence, + coverage_gaps: finding_coverage_gaps, next_evidence: Some(SccmTaskSequenceNextEvidence { logical_artifact_id: TASK_SEQUENCE_LOGICAL_ARTIFACT_ID.to_owned(), path_class, @@ -673,3 +954,21 @@ fn finding_for_coverage(coverage_gaps: &[SccmTaskSequenceCoverageGap]) -> SccmTa }), } } + +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/tests/sccm_client_task_sequence.rs b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence.rs index 1e1c8c8a2..76eccab68 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_task_sequence.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_task_sequence.rs @@ -3,8 +3,8 @@ 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, SccmTaskSequenceCoverageState, - SccmTaskSequenceOrderingState, + SccmClientIntakeCaptureGap, SccmTaskSequenceClassification, SccmTaskSequenceConfidence, + SccmTaskSequenceCoverageState, SccmTaskSequenceOrderingState, }; use cmtraceopen_parser::sccm::{SccmArtifact, SccmCoverageState, SccmRole, SccmRotation}; use serde_json::Value; @@ -52,6 +52,39 @@ 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, @@ -68,6 +101,14 @@ fn rotation(value: &Value) -> SccmRotation { } } +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", @@ -150,9 +191,7 @@ fn admitted_scenario_with_order( 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" && fragment_complete == Some(true)); + 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()))); @@ -285,10 +324,39 @@ fn evidence_projection(value: &Value, make_opaque: bool) -> Vec<(String, u64, u6 .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 expected = read_json(&fixture_root(scenario).join("expected.json")); + 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) @@ -307,15 +375,30 @@ fn every_committed_scenario_runs_through_the_exported_production_reducer() { expected_transactions.len(), "{scenario}: transaction count" ); - for (actual_transaction, expected_transaction) in - actual_transactions.iter().zip(expected_transactions) - { + 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"], @@ -330,6 +413,37 @@ fn every_committed_scenario_runs_through_the_exported_production_reducer() { 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() @@ -345,6 +459,167 @@ fn every_committed_scenario_runs_through_the_exported_production_reducer() { .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!( @@ -352,20 +627,147 @@ fn every_committed_scenario_runs_through_the_exported_production_reducer() { 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 admitted = admitted_scenario("relocated-fragments"); - let wire = serde_json::to_string( - &analyze_client_task_sequence(&admitted).expect("sealed Task Sequence analysis succeeds"), - ) - .expect("Task Sequence analysis serializes"); + 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-0000-0000-0000-000000000006")); + assert!(!wire.contains("72400000")); assert!(!wire.contains("LAB00324")); - assert!(!wire.contains("LAB20306")); + assert!(!wire.contains("LAB203")); assert!(!wire.contains("SYNTHETIC://")); assert!(!wire.contains("_SMSTSLogPath")); assert!(!wire.contains("nativeAcceptance")); @@ -408,6 +810,112 @@ fn same_execution_with_equal_timestamps_is_ambiguous_not_ordered() { 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] @@ -426,6 +934,20 @@ fn identity_fields_split_across_records_never_form_a_transaction() { 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"); From 68e3baa5989213458fde2a475d7e5b57a289ca4f Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 02:33:53 -0400 Subject: [PATCH 399/422] feat(sccm): analyze hierarchy replication workflows --- .../src/sccm/server/windows/catalog.rs | 19 + .../src/sccm/server/windows/hierarchy.rs | 1131 +++++++++++++++++ .../src/sccm/server/windows/mod.rs | 2 + .../tests/sccm_hierarchy_reducer.rs | 548 ++++++++ 4 files changed, 1700 insertions(+) create mode 100644 crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs create mode 100644 crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs index 1cbc4751e..eb6ceb141 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs @@ -40,6 +40,24 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ 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, @@ -180,6 +198,7 @@ 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 } 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..a8210330b --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs @@ -0,0 +1,1131 @@ +//! 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::sccm::{ + normalize_ccm_artifact, SccmArtifact, SccmConfidence, SccmCoverageState, SccmEvidenceRef, + SccmFindingClass, SccmKeyConfidence, SccmRole, SccmTimeOrderingState, SccmTimestamp, +}; + +const PUBLIC_MESSAGE_PREFIX: &str = "[sccm-public-message-v1] "; +const FIXTURE_MARKER: &str = "SYNTHETIC FIXTURE"; +pub const SCCM_HIERARCHY_PROFILE_ID: &str = "hierarchy-server-5.00.test-v1"; +pub const SCCM_HIERARCHY_SOURCE_VERSION: &str = "5.00.TEST.0001"; +pub const SCCM_HIERARCHY_PROFILE_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmHierarchyTarget { + pub site_code: String, + pub host_handle: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmHierarchyTopology { + pub origin_site_code: String, + pub target_site_code: String, + pub origin_host_handle: String, + pub target_host_handle: String, + #[serde(default)] + pub additional_targets: Vec, +} + +#[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 SccmHierarchyArtifact { + pub artifact: SccmArtifact, + pub source_id: String, + pub direction: SccmHierarchyDirection, + pub producer_host_handle: String, + pub rotation_lineage_id: String, + pub fragment_complete: bool, + #[serde(skip)] + pub content: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmHierarchyBundle { + pub profile_id: String, + pub source_version: String, + pub topology: SccmHierarchyTopology, + pub artifacts: Vec, +} + +#[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, + pub confidence: SccmKeyConfidence, + pub extraction_profile_id: 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 { + pub source_id: String, + pub producer_role: SccmRole, + pub direction: SccmHierarchyDirection, + 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 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 cross_side_causal_claims: Vec, + pub native_validation_performed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SccmHierarchyError { + InvalidTopology, +} + +pub fn analyze_hierarchy_replication( + bundle: &SccmHierarchyBundle, +) -> Result { + validate_topology(&bundle.topology)?; + let profile_selected = bundle.profile_id == SCCM_HIERARCHY_PROFILE_ID + && bundle.source_version == SCCM_HIERARCHY_SOURCE_VERSION + && bundle.artifacts.iter().all(|artifact| { + artifact.artifact.configmgr_version.as_deref() == Some(SCCM_HIERARCHY_SOURCE_VERSION) + }); + let mut coverage = Vec::new(); + let mut source_local_observations = Vec::new(); + let mut grouped = BTreeMap::::new(); + let mut invalid_time_requests = + BTreeMap::, BTreeSet)>::new(); + + let mut artifacts = bundle.artifacts.iter().collect::>(); + artifacts.sort_by(|left, right| left.artifact.artifact_id.cmp(&right.artifact.artifact_id)); + for artifact in artifacts { + let topology_ok = artifact_topology_matches(artifact, &bundle.topology); + let state = artifact.artifact.coverage.clone(); + coverage.push(SccmHierarchyCoverage { + artifact_id: artifact.artifact.artifact_id.clone(), + source_id: artifact.source_id.clone(), + producer_role: artifact.artifact.role.clone(), + producer_host_handle: artifact.producer_host_handle.clone(), + state, + }); + + if artifact.artifact.coverage != SccmCoverageState::Captured + || !topology_ok + || !profile_selected + || artifact.artifact.configmgr_version.as_deref() != Some(SCCM_HIERARCHY_SOURCE_VERSION) + || !artifact.fragment_complete + || artifact.rotation_lineage_id.trim().is_empty() + || !declared_source(artifact) + { + if !profile_selected + || artifact.artifact.configmgr_version.as_deref() + != Some(SCCM_HIERARCHY_SOURCE_VERSION) + { + source_local_observations.push(source_local( + artifact, + "unvalidatedProfile", + Vec::new(), + )); + } else if !topology_ok { + source_local_observations.push(source_local( + artifact, + "topologyMismatch", + Vec::new(), + )); + } + continue; + } + + let records = normalize_ccm_artifact(artifact.artifact.clone(), &artifact.content); + for evidence in records { + let Some(parsed) = parse_public_record(&evidence.message) else { + continue; + }; + if parsed + .profile_id + .as_deref() + .is_some_and(|profile_id| 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) + || parsed.origin_site != bundle.topology.origin_site_code + || target_host_for(&bundle.topology, &parsed.target_site).is_none() + || !record_topology_matches(artifact, &parsed, &bundle.topology) + || !phase_owned(artifact, &parsed.phase) + { + source_local_observations.push(source_local( + artifact, + "topologyOrGrammarMismatch", + vec![evidence.reference], + )); + 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, + confidence: SccmKeyConfidence::Exact, + extraction_profile_id: SCCM_HIERARCHY_PROFILE_ID.to_owned(), + }; + let group_key = transaction_id(&key); + if evidence.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc + || evidence.timestamp.utc_millis.is_none() + { + let request = invalid_time_requests + .entry(key.target_site_code.clone()) + .or_default(); + request.0.insert(artifact.direction.clone()); + request.1.insert(artifact.artifact.display_name.clone()); + } + let candidate = grouped.entry(group_key).or_insert_with(|| Candidate { + key, + observations: Vec::new(), + evidence: Vec::new(), + directions: BTreeSet::new(), + }); + candidate.directions.insert(artifact.direction.clone()); + let reference = evidence.reference; + candidate.observations.push(SccmHierarchyObservation { + observation_id: observation_id( + &reference.artifact_id, + &parsed.phase, + &parsed.disposition, + parsed.terminal, + ), + phase: parsed.phase, + disposition: parsed.disposition, + terminal: parsed.terminal, + evidence: vec![reference.clone()], + timestamp: evidence.timestamp, + }); + candidate.evidence.push(reference); + } + } + + coverage.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id)); + source_local_observations.extend(fragment_observations(&bundle.artifacts)); + let mut artifact_requests = artifact_requests(bundle, &coverage); + artifact_requests.extend(invalid_time_requests.into_iter().map( + |(target_site_code, (directions, basenames))| SccmHierarchyArtifactRequest { + source_id: "server-hierarchy-transfer".to_owned(), + producer_role: SccmRole::SiteServer, + direction: if directions.len() == 2 { + SccmHierarchyDirection::Both + } else { + directions + .into_iter() + .next() + .unwrap_or(SccmHierarchyDirection::Origin) + }, + target_site_code, + basenames: basenames.into_iter().collect(), + reason_code: "invalidOffset".to_owned(), + }, + )); + 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(); + let ordering = timestamp_ordering(&candidate.observations); + let 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 gaps = missing_required_artifacts(&coverage, &candidate.observations); + if candidate.observations.len() == 1 + && !terminal_failure + && !terminal_success + && !retrying + && gaps.is_empty() + { + let observation = &candidate.observations[0]; + source_local_observations.push(SccmHierarchySourceLocalObservation { + observation_id: format!( + "{}-unlinked", + artifact_prefix(observation_artifact_id(observation)) + ), + 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 contradictory { + ( + SccmHierarchyState::Contradictory, + Some(SccmFindingClass::InsufficientEvidence), + ) + } 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 transaction_id = transaction_id(&candidate.key); + 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() + && !contradictory + { + SccmHierarchyRemoteCausality::EvidenceBound + } else { + SccmHierarchyRemoteCausality::NotEstablished + }; + let next_artifacts = artifact_requests + .iter() + .filter(|request| request.target_site_code == candidate.key.target_site_code) + .cloned() + .collect::>(); + let target_host_handle = + target_host_for(&bundle.topology, &candidate.key.target_site_code) + .map(str::to_owned); + let correlation_eligible = matches!( + state, + SccmHierarchyState::Succeeded + | SccmHierarchyState::Failed + | SccmHierarchyState::Recovered + ) && remote_causality + == SccmHierarchyRemoteCausality::EvidenceBound; + Some(SccmHierarchyTransaction { + transaction_id, + key: candidate.key, + 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: bundle.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)); + source_local_observations.sort_by(|left, right| left.observation_id.cmp(&right.observation_id)); + + 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, + 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, + 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 validate_topology(topology: &SccmHierarchyTopology) -> Result<(), SccmHierarchyError> { + if !exact_site_code(&topology.origin_site_code) + || !exact_site_code(&topology.target_site_code) + || topology.origin_host_handle.is_empty() + || topology.target_host_handle.is_empty() + || topology.origin_site_code == topology.target_site_code + || topology.origin_host_handle == topology.target_host_handle + { + return Err(SccmHierarchyError::InvalidTopology); + } + let mut sites = BTreeSet::from([ + topology.origin_site_code.as_str(), + topology.target_site_code.as_str(), + ]); + let mut hosts = BTreeSet::from([ + topology.origin_host_handle.as_str(), + topology.target_host_handle.as_str(), + ]); + if topology.additional_targets.iter().any(|target| { + !exact_site_code(&target.site_code) + || target.host_handle.is_empty() + || !sites.insert(&target.site_code) + || !hosts.insert(&target.host_handle) + }) { + return Err(SccmHierarchyError::InvalidTopology); + } + Ok(()) +} + +fn artifact_topology_matches( + artifact: &SccmHierarchyArtifact, + topology: &SccmHierarchyTopology, +) -> bool { + match artifact.direction { + SccmHierarchyDirection::Origin => { + artifact.producer_host_handle == topology.origin_host_handle + } + SccmHierarchyDirection::Target => { + topology.target_host_handle == artifact.producer_host_handle + || topology + .additional_targets + .iter() + .any(|target| target.host_handle == artifact.producer_host_handle) + } + SccmHierarchyDirection::Both => false, + } +} + +fn target_host_for<'a>(topology: &'a SccmHierarchyTopology, site_code: &str) -> Option<&'a str> { + if topology.target_site_code == site_code { + return Some(&topology.target_host_handle); + } + topology + .additional_targets + .iter() + .find(|target| target.site_code == site_code) + .map(|target| target.host_handle.as_str()) +} + +fn record_topology_matches( + artifact: &SccmHierarchyArtifact, + parsed: &ParsedRecord, + topology: &SccmHierarchyTopology, +) -> bool { + match artifact.direction { + SccmHierarchyDirection::Origin => { + artifact.producer_host_handle == topology.origin_host_handle + } + SccmHierarchyDirection::Target => { + target_host_for(topology, &parsed.target_site) + == Some(artifact.producer_host_handle.as_str()) + } + SccmHierarchyDirection::Both => false, + } +} + +fn declared_source(artifact: &SccmHierarchyArtifact) -> bool { + matches!(artifact.artifact.role, SccmRole::SiteServer) + && matches!( + ( + artifact.source_id.as_str(), + artifact.artifact.display_name.as_str(), + &artifact.direction + ), + ( + "server-hierarchy-control", + "replmgr.log", + SccmHierarchyDirection::Origin + ) | ( + "server-hierarchy-control", + "rcmctrl.log", + SccmHierarchyDirection::Target + ) | ( + "server-hierarchy-transfer", + "sender.log", + SccmHierarchyDirection::Origin + ) | ( + "server-hierarchy-transfer", + "sender.lo_", + SccmHierarchyDirection::Origin + ) | ( + "server-hierarchy-transfer", + "despool.log", + SccmHierarchyDirection::Target + ) + ) +} + +fn phase_owned(artifact: &SccmHierarchyArtifact, phase: &SccmHierarchyPhase) -> bool { + matches!( + (artifact.artifact.display_name.as_str(), phase), + ( + "replmgr.log", + SccmHierarchyPhase::Initiate | SccmHierarchyPhase::QueueOrSerialize + ) | ("sender.log" | "sender.lo_", SccmHierarchyPhase::Send) + | ( + "despool.log", + SccmHierarchyPhase::Receive + | SccmHierarchyPhase::Process + | SccmHierarchyPhase::HealthyOrTerminal + ) + | ( + "rcmctrl.log", + SccmHierarchyPhase::Acknowledge | SccmHierarchyPhase::HealthyOrTerminal + ) + ) +} + +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 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( + artifact_id: &str, + 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!("{}-{suffix}", artifact_prefix(artifact_id)) +} + +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 pair in observations.windows(2) { + let (left, right) = (&pair[0], &pair[1]); + let (Some(left_millis), Some(right_millis)) = + (left.timestamp.utc_millis, right.timestamp.utc_millis) + else { + return SccmHierarchyTimestampOrdering::UnusableMissingTimestamp; + }; + if observation_artifact_id(right) != observation_artifact_id(left) + && right_millis <= left_millis + { + return SccmHierarchyTimestampOrdering::Contradictory; + } + if observation_artifact_id(right) == observation_artifact_id(left) + && right_millis < left_millis + { + 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( + coverage: &[SccmHierarchyCoverage], + observations: &[SccmHierarchyObservation], +) -> Vec { + let seen = observations + .iter() + .map(observation_artifact_id) + .collect::>(); + coverage + .iter() + .filter(|artifact| artifact.state != SccmCoverageState::Captured) + .filter(|artifact| !seen.contains(artifact.artifact_id.as_str())) + .map(|artifact| artifact.artifact_id.clone()) + .collect() +} + +fn source_local( + artifact: &SccmHierarchyArtifact, + reason_code: &str, + evidence: Vec, +) -> SccmHierarchySourceLocalObservation { + SccmHierarchySourceLocalObservation { + observation_id: format!( + "{}-{}", + artifact_prefix(&artifact.artifact.artifact_id), + reason_code + ), + finding_class: SccmFindingClass::InsufficientEvidence, + confidence: SccmConfidence::Low, + correlation_eligible: false, + artifact_ids: vec![artifact.artifact.artifact_id.clone()], + evidence, + reason_code: reason_code.to_owned(), + } +} + +fn fragment_observations( + artifacts: &[SccmHierarchyArtifact], +) -> Vec { + let mut groups = BTreeMap::<&str, Vec<&SccmHierarchyArtifact>>::new(); + for artifact in artifacts + .iter() + .filter(|artifact| !artifact.fragment_complete) + { + groups + .entry(&artifact.rotation_lineage_id) + .or_default() + .push(artifact); + } + groups + .into_values() + .map(|mut artifacts| { + artifacts + .sort_by(|left, right| left.artifact.artifact_id.cmp(&right.artifact.artifact_id)); + let artifact_ids = artifacts + .iter() + .map(|artifact| artifact.artifact.artifact_id.clone()) + .collect::>(); + let reason_code = if artifacts.len() > 1 { + "rotationSplit" + } else { + "coverageOnly" + }; + SccmHierarchySourceLocalObservation { + observation_id: format!("{}-{reason_code}", artifact_prefix(&artifact_ids[0])), + finding_class: SccmFindingClass::InsufficientEvidence, + confidence: SccmConfidence::Low, + correlation_eligible: false, + artifact_ids, + evidence: Vec::new(), + reason_code: reason_code.to_owned(), + } + }) + .collect() +} + +fn artifact_requests( + bundle: &SccmHierarchyBundle, + coverage: &[SccmHierarchyCoverage], +) -> Vec { + let mut requests = Vec::new(); + for item in coverage { + let Some(artifact) = bundle + .artifacts + .iter() + .find(|artifact| artifact.artifact.artifact_id == item.artifact_id) + else { + continue; + }; + let reason_code = match item.state { + SccmCoverageState::Absent => Some("coverageAbsent"), + SccmCoverageState::Capped => Some("coverageCapped"), + _ => None, + }; + let Some(reason_code) = reason_code else { + continue; + }; + requests.push(SccmHierarchyArtifactRequest { + source_id: item.source_id.clone(), + producer_role: item.producer_role.clone(), + direction: artifact.direction.clone(), + target_site_code: bundle.topology.target_site_code.clone(), + basenames: vec![artifact.artifact.display_name.clone()], + reason_code: reason_code.to_owned(), + }); + } + let mut rotation_groups = BTreeMap::<&str, Vec<&SccmHierarchyArtifact>>::new(); + for artifact in bundle + .artifacts + .iter() + .filter(|artifact| !artifact.fragment_complete) + { + rotation_groups + .entry(&artifact.rotation_lineage_id) + .or_default() + .push(artifact); + } + for artifacts in rotation_groups + .into_values() + .filter(|group| group.len() > 1) + { + requests.retain(|request| { + !artifacts.iter().any(|artifact| { + request.source_id == artifact.source_id + && request.reason_code == "coverageRotationSplit" + }) + }); + let mut basenames = artifacts + .iter() + .map(|artifact| artifact.artifact.display_name.clone()) + .collect::>(); + basenames.sort(); + basenames.dedup(); + requests.push(SccmHierarchyArtifactRequest { + source_id: artifacts[0].source_id.clone(), + producer_role: SccmRole::SiteServer, + direction: artifacts[0].direction.clone(), + target_site_code: bundle.topology.target_site_code.clone(), + basenames, + reason_code: "coverageRotationSplit".to_owned(), + }); + } + requests.sort_by(request_order); + requests.dedup_by(|left, right| request_order(left, right).is_eq()); + requests +} + +fn request_order( + left: &SccmHierarchyArtifactRequest, + right: &SccmHierarchyArtifactRequest, +) -> Ordering { + ( + left.source_id.as_str(), + &left.direction, + left.target_site_code.as_str(), + left.reason_code.as_str(), + &left.basenames, + ) + .cmp(&( + right.source_id.as_str(), + &right.direction, + right.target_site_code.as_str(), + right.reason_code.as_str(), + &right.basenames, + )) +} + +fn artifact_prefix(artifact_id: &str) -> &str { + artifact_id + .rsplit_once('-') + .map_or(artifact_id, |(prefix, _)| prefix) +} diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs index cb299f3c2..82905d920 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs @@ -1,5 +1,6 @@ mod catalog; mod distribution_point; +mod hierarchy; mod intake; mod management_point; mod site_core; @@ -7,6 +8,7 @@ mod software_update_point; pub use catalog::*; pub use distribution_point::*; +pub use hierarchy::*; pub use intake::*; pub use management_point::*; pub use site_core::*; 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..188899272 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs @@ -0,0 +1,548 @@ +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::server::windows::{ + analyze_hierarchy_replication, declared_server_source_catalog, SccmHierarchyArtifact, + SccmHierarchyBundle, SccmHierarchyDirection, SccmHierarchyProfileSelectionState, + SccmHierarchyState, SccmHierarchyTarget, SccmHierarchyTopology, SCCM_HIERARCHY_PROFILE_ID, + SCCM_HIERARCHY_SOURCE_VERSION, +}; +use cmtraceopen_parser::sccm::{ + SccmArtifact, SccmConfidence, SccmCoverageState, SccmFindingClass, SccmRole, SccmRotation, +}; +use serde_json::{json, Value}; + +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", +]; + +fn corpus_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sccm/server/hierarchy_and_replication") +} + +fn load_bundle(scenario: &str) -> (SccmHierarchyBundle, Value) { + let root = corpus_root().join(scenario); + let manifest: 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 output is readable"), + ) + .expect("expected output is valid JSON"); + let topology = &manifest["topology"]; + let additional_targets = topology["additionalTargets"] + .as_array() + .into_iter() + .flatten() + .map(|target| SccmHierarchyTarget { + site_code: required_str(target, "siteCode").to_owned(), + host_handle: required_str(target, "hostHandle").to_owned(), + }) + .collect(); + let artifacts = manifest["artifacts"] + .as_array() + .expect("artifacts are an array") + .iter() + .map(|artifact| { + let relative_path = artifact["relativePath"].as_str(); + let content = relative_path + .map(|relative_path| { + std::fs::read_to_string(root.join(relative_path)) + .expect("declared hierarchy payload is readable") + }) + .unwrap_or_default(); + let rotation = match required_str(&artifact["rotation"], "kind") { + "current" => SccmRotation::Current, + "loUnderscore" => SccmRotation::LoUnderscore, + value => panic!("unexpected hierarchy rotation {value}"), + }; + let coverage = match required_str(artifact, "captureState") { + "captured" => SccmCoverageState::Captured, + "absent" => SccmCoverageState::Absent, + "accessDenied" => SccmCoverageState::AccessDenied, + "capped" => SccmCoverageState::Capped, + "skipped" => SccmCoverageState::Skipped, + "unsupported" => SccmCoverageState::Unsupported, + "parseFailed" => SccmCoverageState::ParseFailed, + value => panic!("unexpected hierarchy coverage {value}"), + }; + let direction = match required_str(artifact, "direction") { + "origin" => SccmHierarchyDirection::Origin, + "target" => SccmHierarchyDirection::Target, + value => panic!("unexpected hierarchy direction {value}"), + }; + let producer_host_handle = required_str(artifact, "producerHostHandle").to_owned(); + SccmHierarchyArtifact { + artifact: SccmArtifact { + artifact_id: required_str(artifact, "artifactId").to_owned(), + display_name: required_str(artifact, "originalBasename").to_owned(), + original_path: None, + host: Some(producer_host_handle.clone()), + 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: artifact["encoding"].as_str().map(str::to_owned), + }, + source_id: required_str(artifact, "sourceId").to_owned(), + direction, + producer_host_handle, + rotation_lineage_id: required_str(&artifact["rotation"], "lineageId").to_owned(), + fragment_complete: artifact["rotation"]["fragmentComplete"] + .as_bool() + .unwrap_or(true), + content, + } + }) + .collect(); + + ( + SccmHierarchyBundle { + profile_id: SCCM_HIERARCHY_PROFILE_ID.to_owned(), + source_version: SCCM_HIERARCHY_SOURCE_VERSION.to_owned(), + topology: SccmHierarchyTopology { + origin_site_code: required_str(topology, "originSiteCode").to_owned(), + target_site_code: required_str(topology, "targetSiteCode").to_owned(), + origin_host_handle: required_str(topology, "originHostHandle").to_owned(), + target_host_handle: required_str(topology, "targetHostHandle").to_owned(), + additional_targets, + }, + artifacts, + }, + expected, + ) +} + +fn required_str<'a>(value: &'a Value, field: &str) -> &'a str { + value[field] + .as_str() + .unwrap_or_else(|| panic!("{field} is a string")) +} + +#[test] +fn every_corpus_scenario_runs_through_the_exported_production_analyzer() { + for scenario in SCENARIOS { + let (bundle, expected) = load_bundle(scenario); + let analysis = analyze_hierarchy_replication(&bundle).expect("topology is accepted"); + 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!( + 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), + expected["coverage"], + "coverage mismatch for {scenario}" + ); + assert_eq!( + transaction_projection(&analysis), + transaction_expectation(&expected), + "transaction mismatch for {scenario}" + ); + assert_eq!( + source_local_projection(&analysis), + source_local_expectation(&expected), + "source-local mismatch for {scenario}" + ); + assert_eq!( + request_projection(&analysis), + expected["artifactRequests"], + "request mismatch for {scenario}" + ); + + for transaction in &analysis.transactions { + assert_eq!(transaction.producer_role, SccmRole::SiteServer); + assert_eq!(transaction.source_version, SCCM_HIERARCHY_SOURCE_VERSION); + assert!( + transaction.last_successful_phase.is_some() + || transaction.state == SccmHierarchyState::Deferred + || transaction.state == SccmHierarchyState::Failed + ); + assert_eq!( + transaction.next_artifacts, + analysis + .artifact_requests + .iter() + .filter(|request| request.target_site_code == transaction.key.target_site_code) + .cloned() + .collect::>() + ); + assert!(transaction.observations.iter().all(|observation| { + !observation.evidence.is_empty() + && observation.evidence.iter().all(|reference| { + reference.line_start.is_some() && reference.line_end.is_some() + }) + })); + } + + let mut reversed = bundle.clone(); + reversed.artifacts.reverse(); + assert_eq!( + serde_json::to_value(&analysis).expect("analysis serializes"), + serde_json::to_value( + analyze_hierarchy_replication(&reversed).expect("reversed topology is accepted") + ) + .expect("reversed analysis serializes"), + "input order changed full output for {scenario}" + ); + } +} + +#[test] +fn framed_hierarchy_grammar_does_not_depend_on_the_synthetic_marker_or_profile_claim() { + let (mut bundle, _) = load_bundle("healthy-link"); + for artifact in &mut bundle.artifacts { + artifact.content = artifact + .content + .replace("SYNTHETIC FIXTURE; ", "") + .replace("; ProfileId=hierarchy-server-5.00.test-v1", ""); + } + + let analysis = analyze_hierarchy_replication(&bundle).expect("topology is accepted"); + assert_eq!(analysis.transactions.len(), 1); + assert_eq!( + analysis.transactions[0].state, + SccmHierarchyState::Succeeded + ); + assert_eq!(analysis.transactions[0].confidence, SccmConfidence::High); +} + +#[test] +fn arbitrary_profiles_and_source_versions_fail_closed() { + let (mut arbitrary_profile, _) = load_bundle("healthy-link"); + arbitrary_profile.profile_id = "caller-attested-profile".to_owned(); + let analysis = + analyze_hierarchy_replication(&arbitrary_profile).expect("topology remains valid"); + assert!(analysis.transactions.is_empty()); + assert_eq!( + analysis.extraction_profile.selection_state, + SccmHierarchyProfileSelectionState::Unavailable + ); + assert!(analysis + .source_local_observations + .iter() + .all(|observation| observation.reason_code == "unvalidatedProfile")); + + let (mut unknown_version, _) = load_bundle("healthy-link"); + unknown_version.source_version = "5.00.UNKNOWN.0001".to_owned(); + let analysis = analyze_hierarchy_replication(&unknown_version).expect("topology remains valid"); + assert!(analysis.transactions.is_empty()); + assert_eq!( + analysis.extraction_profile.selection_state, + SccmHierarchyProfileSelectionState::Unavailable + ); +} + +#[test] +fn exact_message_and_link_kinds_reject_content_like_or_partial_values() { + let (mut bundle, _) = load_bundle("sender-failure"); + bundle.artifacts[0].content = bundle.artifacts[0] + .content + .replace("MessageId=msg-send-chd", "MessageId=content-chd") + .replace("LinkId=link-lab-sec", "LinkId=content-lab-sec"); + let analysis = analyze_hierarchy_replication(&bundle).expect("topology is accepted"); + assert!(analysis.transactions.is_empty()); + assert!(analysis + .source_local_observations + .iter() + .all(|observation| !observation.correlation_eligible)); +} + +#[test] +fn terminal_and_remote_causality_are_conservative() { + let (sender, _) = load_bundle("sender-failure"); + let sender = analyze_hierarchy_replication(&sender).expect("topology is accepted"); + assert!(sender.transactions.iter().all(|transaction| { + transaction.state == SccmHierarchyState::Failed + && transaction.finding_class == Some(SccmFindingClass::ConfirmedFailure) + && !transaction.correlation_eligible + })); + + let (mut receiver, _) = load_bundle("receiver-processing-failure"); + let despool = receiver + .artifacts + .iter_mut() + .find(|artifact| artifact.artifact.display_name == "despool.log") + .expect("despool artifact exists"); + despool.content = despool + .content + .lines() + .next() + .expect("receiver fact exists") + .replace( + "Disposition=succeeded; Terminal=false", + "Disposition=failed; Terminal=true", + ); + let receiver = analyze_hierarchy_replication(&receiver).expect("topology is accepted"); + assert_eq!(receiver.transactions[0].state, SccmHierarchyState::Failed); + assert_eq!(receiver.transactions[0].confidence, SccmConfidence::High); + assert_eq!( + receiver.transactions[0].finding_class, + Some(SccmFindingClass::ConfirmedFailure) + ); + + for scenario in ["healthy-link", "receiver-processing-failure", "recovery"] { + let (bundle, _) = load_bundle(scenario); + let analysis = analyze_hierarchy_replication(&bundle).expect("topology is accepted"); + assert!(analysis + .transactions + .iter() + .all(|transaction| transaction.correlation_eligible)); + } +} + +#[test] +fn contradiction_malformed_grammar_and_topology_mutations_fail_closed() { + let (mut contradictory, _) = load_bundle("healthy-link"); + let sender = contradictory + .artifacts + .iter_mut() + .find(|artifact| artifact.artifact.display_name == "sender.log") + .expect("sender artifact exists"); + let failed = sender + .content + .replace( + "Disposition=succeeded; Terminal=false", + "Disposition=failed; Terminal=true", + ) + .replace("15:03:02.000+000", "15:03:02.500+000"); + sender.content = format!("{}\n{failed}", sender.content); + let analysis = analyze_hierarchy_replication(&contradictory).expect("topology is accepted"); + assert_eq!( + analysis.transactions[0].state, + SccmHierarchyState::Contradictory + ); + assert_eq!(analysis.transactions[0].confidence, SccmConfidence::Low); + assert!(!analysis.transactions[0].correlation_eligible); + + let (mut malformed, _) = load_bundle("sender-failure"); + malformed.artifacts[0].content = malformed.artifacts[0] + .content + .replace("MessageId=msg-", "MessageId=msg/"); + let analysis = analyze_hierarchy_replication(&malformed).expect("topology is accepted"); + assert!(analysis.transactions.is_empty()); + assert!(analysis + .source_local_observations + .iter() + .all(|observation| !observation.correlation_eligible)); + + let (mut wrong_role, _) = load_bundle("healthy-link"); + for artifact in &mut wrong_role.artifacts { + artifact.artifact.role = SccmRole::ManagementPoint; + } + assert!(analyze_hierarchy_replication(&wrong_role) + .expect("topology is accepted") + .transactions + .is_empty()); + + let (mut wrong_host, _) = load_bundle("receiver-processing-failure"); + wrong_host.artifacts[1].producer_host_handle = wrong_host.topology.origin_host_handle.clone(); + let analysis = analyze_hierarchy_replication(&wrong_host).expect("topology is accepted"); + assert!(analysis.transactions.iter().all(|transaction| { + transaction.state == SccmHierarchyState::Incomplete && !transaction.correlation_eligible + })); + + let (mut artifact_versions, _) = load_bundle("healthy-link"); + for artifact in &mut artifact_versions.artifacts { + artifact.artifact.configmgr_version = Some("5.00.UNKNOWN.0001".to_owned()); + } + let analysis = analyze_hierarchy_replication(&artifact_versions).expect("topology is accepted"); + assert!(analysis.transactions.is_empty()); + assert!(analysis + .source_local_observations + .iter() + .all(|observation| observation.reason_code == "unvalidatedProfile")); +} + +#[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 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 transaction_projection( + analysis: &cmtraceopen_parser::sccm::server::windows::SccmHierarchyAnalysis, +) -> Value { + Value::Array( + analysis + .transactions + .iter() + .map(|transaction| { + json!({ + "transactionId": transaction.transaction_id, + "key": transaction.key, + "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 are an array").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 { + serde_json::to_value(&analysis.artifact_requests).expect("requests serialize") +} From 5dca3db3aa1ecdf0016ef1305f70a50257d33a00 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 03:13:04 -0400 Subject: [PATCH 400/422] fix(sccm): seal hierarchy replication authority --- .../cmtraceopen-parser/src/sccm/findings.rs | 23 +- crates/cmtraceopen-parser/src/sccm/keys.rs | 94 +- crates/cmtraceopen-parser/src/sccm/models.rs | 2 + .../src/sccm/server/windows/hierarchy.rs | 1131 ++++++++++++----- .../src/sccm/server/windows/intake.rs | 164 ++- .../tests/sccm_hierarchy_reducer.rs | 697 ++++++---- 6 files changed, 1493 insertions(+), 618 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index bfcfe4e56..7feb8c245 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::models::log_entry::Severity; use super::catalog::declared_source_catalog; -use super::keys::normalize_key; +use super::keys::{normalize_key, SCCM_HIERARCHY_KEY_PROFILE_ID}; use super::models::{ SccmCorrelationKey, SccmCorrelationKeyKind, SccmCoverageState, SccmEvidenceRef, SccmFindingClass, SccmKeyConfidence, SccmRole, @@ -41,7 +41,8 @@ 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"]; +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")] @@ -2696,14 +2697,16 @@ fn correlation_key_kind_order(kind: &SccmCorrelationKeyKind) -> u8 { SccmCorrelationKeyKind::RequestId => 12, SccmCorrelationKeyKind::TopicId => 13, SccmCorrelationKeyKind::StateMessageId => 14, - SccmCorrelationKeyKind::InventoryCycleId => 15, - SccmCorrelationKeyKind::ReportId => 16, - SccmCorrelationKeyKind::ResourceHandle => 17, - SccmCorrelationKeyKind::ComplianceCiId => 18, - SccmCorrelationKeyKind::BaselineId => 19, - SccmCorrelationKeyKind::ComplianceStateId => 20, - SccmCorrelationKeyKind::MeteringCycleId => 21, - SccmCorrelationKeyKind::RuleId => 22, + 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/keys.rs b/crates/cmtraceopen-parser/src/sccm/keys.rs index 11fc82624..261520acc 100644 --- a/crates/cmtraceopen-parser/src/sccm/keys.rs +++ b/crates/cmtraceopen-parser/src/sccm/keys.rs @@ -15,8 +15,10 @@ pub const SCCM_EXPERIMENTAL_KEY_PROFILE_ID: &str = "sccm-keys-5.00.9128-experime /// `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"; 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"; impl SccmExtractionProfile { pub fn for_version(configmgr_version: Option<&str>) -> Self { @@ -65,6 +67,17 @@ impl SccmExtractionProfile { 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(POLICY_TEST_VERSION) && matches!(family, SccmArtifactFamily::ClientPolicy) { @@ -169,6 +182,32 @@ fn key_patterns() -> &'static [KeyPattern] { }) } +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 { @@ -202,7 +241,7 @@ fn extract_keys_with_authority( profile: &SccmExtractionProfile, admitted_profile_authority: bool, ) -> SccmKeyExtractionResult { - let candidates = find_candidates(&evidence.message); + let candidates = find_candidates(&evidence.message, profile); let mut result = SccmKeyExtractionResult { profile_id: profile.profile_id.clone(), keys: Vec::new(), @@ -223,7 +262,8 @@ fn extract_keys_with_authority( } let stable_policy = admitted_profile_authority && is_builtin_stable_policy(profile); - if !stable_policy { + let stable_hierarchy = is_builtin_stable_hierarchy(profile); + if !stable_policy && !stable_hierarchy { result.gaps.push(gap_for( SccmExtractionGapKind::ExperimentalProfile, profile, @@ -249,7 +289,7 @@ fn extract_keys_with_authority( continue; } - key.confidence = if stable_policy { + key.confidence = if stable_policy || stable_hierarchy { SccmKeyConfidence::Exact } else { SccmKeyConfidence::Low @@ -290,12 +330,21 @@ fn profile_gap_kind( { 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 @@ -335,8 +384,13 @@ fn is_canonical_configmgr_version(version: &str) -> bool { component_count == 4 } -fn find_candidates(message: &str) -> Vec> { - let mut candidates = key_patterns() +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| { @@ -436,6 +490,8 @@ fn normalize_value(kind: &SccmCorrelationKeyKind, raw: &str) -> (String, SccmKey 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 @@ -452,6 +508,16 @@ fn normalize_value(kind: &SccmCorrelationKeyKind, raw: &str) -> (String, SccmKey ) } +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) @@ -562,13 +628,15 @@ fn key_kind_order(kind: &SccmCorrelationKeyKind) -> u8 { SccmCorrelationKeyKind::RequestId => 12, SccmCorrelationKeyKind::TopicId => 13, SccmCorrelationKeyKind::StateMessageId => 14, - SccmCorrelationKeyKind::InventoryCycleId => 15, - SccmCorrelationKeyKind::ReportId => 16, - SccmCorrelationKeyKind::ResourceHandle => 17, - SccmCorrelationKeyKind::ComplianceCiId => 18, - SccmCorrelationKeyKind::BaselineId => 19, - SccmCorrelationKeyKind::ComplianceStateId => 20, - SccmCorrelationKeyKind::MeteringCycleId => 21, - SccmCorrelationKeyKind::RuleId => 22, + 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/models.rs b/crates/cmtraceopen-parser/src/sccm/models.rs index 1b8984a82..d85c4833f 100644 --- a/crates/cmtraceopen-parser/src/sccm/models.rs +++ b/crates/cmtraceopen-parser/src/sccm/models.rs @@ -197,6 +197,8 @@ pub enum SccmCorrelationKeyKind { RequestId, TopicId, StateMessageId, + HierarchyMessageId, + HierarchyLinkId, InventoryCycleId, ReportId, ResourceHandle, diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs index a8210330b..be3d8423e 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs @@ -9,34 +9,122 @@ 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::{ - normalize_ccm_artifact, SccmArtifact, SccmConfidence, SccmCoverageState, SccmEvidenceRef, - SccmFindingClass, SccmKeyConfidence, SccmRole, SccmTimeOrderingState, SccmTimestamp, + 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 = "hierarchy-server-5.00.test-v1"; -pub const SCCM_HIERARCHY_SOURCE_VERSION: &str = "5.00.TEST.0001"; +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; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SccmHierarchyTarget { - pub site_code: String, - pub host_handle: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SccmHierarchyTopology { - pub origin_site_code: String, - pub target_site_code: String, - pub origin_host_handle: String, - pub target_host_handle: String, - #[serde(default)] - pub additional_targets: Vec, -} +// 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")] @@ -46,28 +134,6 @@ pub enum SccmHierarchyDirection { Both, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SccmHierarchyArtifact { - pub artifact: SccmArtifact, - pub source_id: String, - pub direction: SccmHierarchyDirection, - pub producer_host_handle: String, - pub rotation_lineage_id: String, - pub fragment_complete: bool, - #[serde(skip)] - pub content: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SccmHierarchyBundle { - pub profile_id: String, - pub source_version: String, - pub topology: SccmHierarchyTopology, - pub artifacts: Vec, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SccmHierarchyCoverage { @@ -155,8 +221,6 @@ pub struct SccmHierarchyKey { pub link_id: String, pub origin_site_code: String, pub target_site_code: String, - pub confidence: SccmKeyConfidence, - pub extraction_profile_id: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -196,9 +260,12 @@ pub enum SccmHierarchyRemoteCausality { #[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, @@ -209,6 +276,7 @@ pub struct SccmHierarchyArtifactRequest { 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, @@ -269,92 +337,112 @@ pub struct SccmHierarchyAnalysis { 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 { - InvalidTopology, + UntrustedIntake, + InvalidFindingContract, } pub fn analyze_hierarchy_replication( - bundle: &SccmHierarchyBundle, + intake: &SccmServerIntakeAssessment, ) -> Result { - validate_topology(&bundle.topology)?; - let profile_selected = bundle.profile_id == SCCM_HIERARCHY_PROFILE_ID - && bundle.source_version == SCCM_HIERARCHY_SOURCE_VERSION - && bundle.artifacts.iter().all(|artifact| { - artifact.artifact.configmgr_version.as_deref() == Some(SCCM_HIERARCHY_SOURCE_VERSION) + 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(); - let mut invalid_time_requests = - BTreeMap::, BTreeSet)>::new(); - let mut artifacts = bundle.artifacts.iter().collect::>(); - artifacts.sort_by(|left, right| left.artifact.artifact_id.cmp(&right.artifact.artifact_id)); - for artifact in artifacts { - let topology_ok = artifact_topology_matches(artifact, &bundle.topology); - let state = artifact.artifact.coverage.clone(); + for artifact in &artifacts { + let state = artifact.state.clone(); coverage.push(SccmHierarchyCoverage { - artifact_id: artifact.artifact.artifact_id.clone(), + artifact_id: artifact.artifact_id.clone(), source_id: artifact.source_id.clone(), - producer_role: artifact.artifact.role.clone(), - producer_host_handle: artifact.producer_host_handle.clone(), + producer_role: artifact.producer_role.clone(), + producer_host_handle: artifact.producer_host_handle.clone().unwrap_or_default(), state, }); - if artifact.artifact.coverage != SccmCoverageState::Captured - || !topology_ok - || !profile_selected - || artifact.artifact.configmgr_version.as_deref() != Some(SCCM_HIERARCHY_SOURCE_VERSION) - || !artifact.fragment_complete - || artifact.rotation_lineage_id.trim().is_empty() - || !declared_source(artifact) + if !profile_selected + || artifact.state != SccmCoverageState::Captured + || !sealed_profile_artifact(artifact) { - if !profile_selected - || artifact.artifact.configmgr_version.as_deref() - != Some(SCCM_HIERARCHY_SOURCE_VERSION) - { + if artifact.state == SccmCoverageState::Captured || !profile_selected { source_local_observations.push(source_local( artifact, "unvalidatedProfile", Vec::new(), )); - } else if !topology_ok { - source_local_observations.push(source_local( - artifact, - "topologyMismatch", - Vec::new(), - )); + } else if matches!( + artifact.state, + SccmCoverageState::Capped | SccmCoverageState::ParseFailed + ) { + source_local_observations.push(source_local(artifact, "coverageOnly", Vec::new())); } continue; } - let records = normalize_ccm_artifact(artifact.artifact.clone(), &artifact.content); - for evidence in records { + 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; }; - if parsed - .profile_id - .as_deref() - .is_some_and(|profile_id| profile_id != SCCM_HIERARCHY_PROFILE_ID) + 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) - || parsed.origin_site != bundle.topology.origin_site_code - || target_host_for(&bundle.topology, &parsed.target_site).is_none() - || !record_topology_matches(artifact, &parsed, &bundle.topology) + || !record_topology_matches(intake, artifact, &parsed) || !phase_owned(artifact, &parsed.phase) { source_local_observations.push(source_local( artifact, "topologyOrGrammarMismatch", - vec![evidence.reference], + vec![evidence.reference.clone()], )); continue; } @@ -364,30 +452,23 @@ pub fn analyze_hierarchy_replication( link_id: parsed.link_id, origin_site_code: parsed.origin_site, target_site_code: parsed.target_site, - confidence: SccmKeyConfidence::Exact, - extraction_profile_id: SCCM_HIERARCHY_PROFILE_ID.to_owned(), }; let group_key = transaction_id(&key); - if evidence.timestamp.ordering_state != SccmTimeOrderingState::NormalizedUtc - || evidence.timestamp.utc_millis.is_none() - { - let request = invalid_time_requests - .entry(key.target_site_code.clone()) - .or_default(); - request.0.insert(artifact.direction.clone()); - request.1.insert(artifact.artifact.display_name.clone()); - } 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.clone()); - let reference = evidence.reference; + 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.artifact_id, + &reference, &parsed.phase, &parsed.disposition, parsed.terminal, @@ -396,32 +477,38 @@ pub fn analyze_hierarchy_replication( disposition: parsed.disposition, terminal: parsed.terminal, evidence: vec![reference.clone()], - timestamp: evidence.timestamp, + timestamp: evidence.timestamp.clone(), }); candidate.evidence.push(reference); } } coverage.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id)); - source_local_observations.extend(fragment_observations(&bundle.artifacts)); - let mut artifact_requests = artifact_requests(bundle, &coverage); - artifact_requests.extend(invalid_time_requests.into_iter().map( - |(target_site_code, (directions, basenames))| SccmHierarchyArtifactRequest { - source_id: "server-hierarchy-transfer".to_owned(), - producer_role: SccmRole::SiteServer, - direction: if directions.len() == 2 { - SccmHierarchyDirection::Both - } else { - directions - .into_iter() - .next() - .unwrap_or(SccmHierarchyDirection::Origin) - }, - target_site_code, - basenames: basenames.into_iter().collect(), - reason_code: "invalidOffset".to_owned(), - }, - )); + 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()); @@ -431,8 +518,11 @@ pub fn analyze_hierarchy_replication( 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 = has_contradiction(&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 }); @@ -444,7 +534,7 @@ pub fn analyze_hierarchy_replication( observation.terminal && observation.disposition == SccmHierarchyDisposition::Succeeded }); - let gaps = missing_required_artifacts(&coverage, &candidate.observations); + let gaps = missing_required_artifacts(intake, &artifacts, &candidate.key); if candidate.observations.len() == 1 && !terminal_failure && !terminal_success @@ -453,10 +543,7 @@ pub fn analyze_hierarchy_replication( { let observation = &candidate.observations[0]; source_local_observations.push(SccmHierarchySourceLocalObservation { - observation_id: format!( - "{}-unlinked", - artifact_prefix(observation_artifact_id(observation)) - ), + observation_id: format!("{}-unlinked", observation.observation_id), finding_class: SccmFindingClass::InsufficientEvidence, confidence: SccmConfidence::Low, correlation_eligible: false, @@ -470,7 +557,7 @@ pub fn analyze_hierarchy_replication( let (state, finding_class) = if contradictory { ( SccmHierarchyState::Contradictory, - Some(SccmFindingClass::InsufficientEvidence), + Some(SccmFindingClass::Symptom), ) } else if unusable_time || !gaps.is_empty() { ( @@ -526,12 +613,18 @@ pub fn analyze_hierarchy_replication( }; let next_artifacts = artifact_requests .iter() - .filter(|request| request.target_site_code == candidate.key.target_site_code) - .cloned() + .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::>(); - let target_host_handle = - target_host_for(&bundle.topology, &candidate.key.target_site_code) - .map(str::to_owned); + let topology = topology_link(intake, &candidate.key)?; + let target_host_handle = Some(topology.target_host_handle.clone()); let correlation_eligible = matches!( state, SccmHierarchyState::Succeeded @@ -542,6 +635,7 @@ pub fn analyze_hierarchy_replication( Some(SccmHierarchyTransaction { transaction_id, key: candidate.key, + correlation_keys: candidate.correlation_keys, topology_compatibility: SccmHierarchyTopologyCompatibility::Exact, timestamp_ordering: ordering, terminal_evidence: candidate @@ -554,7 +648,7 @@ pub fn analyze_hierarchy_replication( confidence_ceiling: confidence, producer_role: SccmRole::SiteServer, source_version: SCCM_HIERARCHY_SOURCE_VERSION.to_owned(), - origin_host_handle: bundle.topology.origin_host_handle.clone(), + origin_host_handle: topology.origin_host_handle.clone(), target_host_handle, last_successful_phase, remote_causality, @@ -567,7 +661,49 @@ pub fn analyze_hierarchy_replication( }) .collect::>(); transactions.sort_by(|left, right| left.transaction_id.cmp(&right.transaction_id)); + for transaction in &mut transactions { + if transaction.timestamp_ordering == SccmHierarchyTimestampOrdering::Usable { + 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(), @@ -588,6 +724,7 @@ pub fn analyze_hierarchy_replication( coverage, source_local_observations, artifact_requests, + findings, cross_side_causal_claims: Vec::new(), native_validation_performed: false, }) @@ -607,6 +744,7 @@ fn state_chain() -> Vec { struct Candidate { key: SccmHierarchyKey, + correlation_keys: Vec, observations: Vec, evidence: Vec, directions: BTreeSet, @@ -623,134 +761,157 @@ struct ParsedRecord { profile_id: Option, } -fn validate_topology(topology: &SccmHierarchyTopology) -> Result<(), SccmHierarchyError> { - if !exact_site_code(&topology.origin_site_code) - || !exact_site_code(&topology.target_site_code) - || topology.origin_host_handle.is_empty() - || topology.target_host_handle.is_empty() - || topology.origin_site_code == topology.target_site_code - || topology.origin_host_handle == topology.target_host_handle - { - return Err(SccmHierarchyError::InvalidTopology); - } - let mut sites = BTreeSet::from([ - topology.origin_site_code.as_str(), - topology.target_site_code.as_str(), - ]); - let mut hosts = BTreeSet::from([ - topology.origin_host_handle.as_str(), - topology.target_host_handle.as_str(), - ]); - if topology.additional_targets.iter().any(|target| { - !exact_site_code(&target.site_code) - || target.host_handle.is_empty() - || !sites.insert(&target.site_code) - || !hosts.insert(&target.host_handle) - }) { - return Err(SccmHierarchyError::InvalidTopology); - } - Ok(()) +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 artifact_topology_matches( - artifact: &SccmHierarchyArtifact, - topology: &SccmHierarchyTopology, +fn record_topology_matches( + intake: &SccmServerIntakeAssessment, + artifact: &SccmServerArtifactAssessment, + parsed: &ParsedRecord, ) -> bool { - match artifact.direction { - SccmHierarchyDirection::Origin => { - artifact.producer_host_handle == topology.origin_host_handle + 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()) } - SccmHierarchyDirection::Target => { - topology.target_host_handle == artifact.producer_host_handle - || topology - .additional_targets - .iter() - .any(|target| target.host_handle == artifact.producer_host_handle) + Some(SccmHierarchyDirection::Target) => { + artifact.producer_host_handle.as_deref() == Some(link.target_host_handle.as_str()) } - SccmHierarchyDirection::Both => false, + Some(SccmHierarchyDirection::Both) | None => false, } } -fn target_host_for<'a>(topology: &'a SccmHierarchyTopology, site_code: &str) -> Option<&'a str> { - if topology.target_site_code == site_code { - return Some(&topology.target_host_handle); - } - topology - .additional_targets - .iter() - .find(|target| target.site_code == site_code) - .map(|target| target.host_handle.as_str()) +fn sealed_profile_artifact(artifact: &SccmServerArtifactAssessment) -> bool { + artifact.state == SccmCoverageState::Captured + && artifact.parser_eligible + && registered_profile_provenance(artifact) + && declared_source(artifact) } -fn record_topology_matches( - artifact: &SccmHierarchyArtifact, - parsed: &ParsedRecord, - topology: &SccmHierarchyTopology, -) -> bool { - match artifact.direction { - SccmHierarchyDirection::Origin => { - artifact.producer_host_handle == topology.origin_host_handle - } - SccmHierarchyDirection::Target => { - target_host_for(topology, &parsed.target_site) - == Some(artifact.producer_host_handle.as_str()) - } - SccmHierarchyDirection::Both => false, +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: &SccmHierarchyArtifact) -> bool { - matches!(artifact.artifact.role, SccmRole::SiteServer) +fn declared_source(artifact: &SccmServerArtifactAssessment) -> bool { + artifact.producer_role == SccmRole::SiteServer && matches!( ( artifact.source_id.as_str(), - artifact.artifact.display_name.as_str(), - &artifact.direction + artifact.original_basename.as_deref(), + artifact_direction(artifact) ), ( "server-hierarchy-control", - "replmgr.log", - SccmHierarchyDirection::Origin + Some("replmgr.log"), + Some(SccmHierarchyDirection::Origin) ) | ( "server-hierarchy-control", - "rcmctrl.log", - SccmHierarchyDirection::Target + Some("rcmctrl.log"), + Some(SccmHierarchyDirection::Target) ) | ( "server-hierarchy-transfer", - "sender.log", - SccmHierarchyDirection::Origin + Some("sender.log"), + Some(SccmHierarchyDirection::Origin) ) | ( "server-hierarchy-transfer", - "sender.lo_", - SccmHierarchyDirection::Origin + Some("sender.lo_"), + Some(SccmHierarchyDirection::Origin) ) | ( "server-hierarchy-transfer", - "despool.log", - SccmHierarchyDirection::Target + Some("despool.log"), + Some(SccmHierarchyDirection::Target) ) ) } -fn phase_owned(artifact: &SccmHierarchyArtifact, phase: &SccmHierarchyPhase) -> bool { +fn phase_owned(artifact: &SccmServerArtifactAssessment, phase: &SccmHierarchyPhase) -> bool { matches!( - (artifact.artifact.display_name.as_str(), phase), + (artifact.original_basename.as_deref(), phase), ( - "replmgr.log", + Some("replmgr.log"), SccmHierarchyPhase::Initiate | SccmHierarchyPhase::QueueOrSerialize - ) | ("sender.log" | "sender.lo_", SccmHierarchyPhase::Send) + ) | (Some("sender.log" | "sender.lo_"), SccmHierarchyPhase::Send) | ( - "despool.log", + Some("despool.log"), SccmHierarchyPhase::Receive | SccmHierarchyPhase::Process | SccmHierarchyPhase::HealthyOrTerminal ) | ( - "rcmctrl.log", + 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(); @@ -808,6 +969,12 @@ fn parse_public_record(message: &str) -> Option { }) } +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-") } @@ -850,7 +1017,7 @@ fn phase_name(phase: &SccmHierarchyPhase) -> &'static str { } fn observation_id( - artifact_id: &str, + reference: &SccmEvidenceRef, phase: &SccmHierarchyPhase, disposition: &SccmHierarchyDisposition, terminal: bool, @@ -866,7 +1033,12 @@ fn observation_id( } else { phase_name(phase) }; - format!("{}-{suffix}", artifact_prefix(artifact_id)) + 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 { @@ -918,22 +1090,29 @@ fn timestamp_ordering(observations: &[SccmHierarchyObservation]) -> SccmHierarch } } } - for pair in observations.windows(2) { - let (left, right) = (&pair[0], &pair[1]); - let (Some(left_millis), Some(right_millis)) = - (left.timestamp.utc_millis, right.timestamp.utc_millis) - else { - return SccmHierarchyTimestampOrdering::UnusableMissingTimestamp; - }; - if observation_artifact_id(right) != observation_artifact_id(left) - && right_millis <= left_millis - { - return SccmHierarchyTimestampOrdering::Contradictory; - } - if observation_artifact_id(right) == observation_artifact_id(left) - && right_millis < left_millis - { - return SccmHierarchyTimestampOrdering::Contradictory; + 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 @@ -958,150 +1137,426 @@ fn has_contradiction(observations: &[SccmHierarchyObservation]) -> bool { } fn missing_required_artifacts( - coverage: &[SccmHierarchyCoverage], - observations: &[SccmHierarchyObservation], + intake: &SccmServerIntakeAssessment, + artifacts: &[&SccmServerArtifactAssessment], + key: &SccmHierarchyKey, ) -> Vec { - let seen = observations - .iter() - .map(observation_artifact_id) - .collect::>(); - coverage + let Some(link) = topology_link(intake, key) else { + return Vec::new(); + }; + let mut gaps = artifacts .iter() .filter(|artifact| artifact.state != SccmCoverageState::Captured) - .filter(|artifact| !seen.contains(artifact.artifact_id.as_str())) + .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() + .collect::>(); + gaps.sort(); + gaps } fn source_local( - artifact: &SccmHierarchyArtifact, + 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!( - "{}-{}", - artifact_prefix(&artifact.artifact.artifact_id), - reason_code + "hierarchy-source:{}:{evidence_identity}:{reason_code}", + artifact.artifact_id ), finding_class: SccmFindingClass::InsufficientEvidence, confidence: SccmConfidence::Low, correlation_eligible: false, - artifact_ids: vec![artifact.artifact.artifact_id.clone()], + artifact_ids: vec![artifact.artifact_id.clone()], evidence, reason_code: reason_code.to_owned(), } } -fn fragment_observations( - artifacts: &[SccmHierarchyArtifact], -) -> Vec { - let mut groups = BTreeMap::<&str, Vec<&SccmHierarchyArtifact>>::new(); - for artifact in artifacts - .iter() - .filter(|artifact| !artifact.fragment_complete) - { - groups - .entry(&artifact.rotation_lineage_id) - .or_default() - .push(artifact); - } - groups - .into_values() - .map(|mut artifacts| { - artifacts - .sort_by(|left, right| left.artifact.artifact_id.cmp(&right.artifact.artifact_id)); - let artifact_ids = artifacts - .iter() - .map(|artifact| artifact.artifact.artifact_id.clone()) - .collect::>(); - let reason_code = if artifacts.len() > 1 { - "rotationSplit" - } else { - "coverageOnly" - }; - SccmHierarchySourceLocalObservation { - observation_id: format!("{}-{reason_code}", artifact_prefix(&artifact_ids[0])), - finding_class: SccmFindingClass::InsufficientEvidence, - confidence: SccmConfidence::Low, - correlation_eligible: false, - artifact_ids, - evidence: Vec::new(), - reason_code: reason_code.to_owned(), - } - }) - .collect() -} - -fn artifact_requests( - bundle: &SccmHierarchyBundle, - coverage: &[SccmHierarchyCoverage], +fn coverage_requests( + intake: &SccmServerIntakeAssessment, + artifacts: &[&SccmServerArtifactAssessment], ) -> Vec { let mut requests = Vec::new(); - for item in coverage { - let Some(artifact) = bundle - .artifacts - .iter() - .find(|artifact| artifact.artifact.artifact_id == item.artifact_id) - else { - continue; - }; - let reason_code = match item.state { + 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 { - source_id: item.source_id.clone(), - producer_role: item.producer_role.clone(), - direction: artifact.direction.clone(), - target_site_code: bundle.topology.target_site_code.clone(), - basenames: vec![artifact.artifact.display_name.clone()], + 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(), }); } - let mut rotation_groups = BTreeMap::<&str, Vec<&SccmHierarchyArtifact>>::new(); - for artifact in bundle - .artifacts + requests.sort_by(request_order); + requests.dedup_by(|left, right| request_order(left, right).is_eq()); + requests +} + +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(|artifact| !artifact.fragment_complete) - { - rotation_groups - .entry(&artifact.rotation_lineage_id) + .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); } - for artifacts in rotation_groups - .into_values() - .filter(|group| group.len() > 1) + + 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 { - requests.retain(|request| { - !artifacts.iter().any(|artifact| { - request.source_id == artifact.source_id - && request.reason_code == "coverageRotationSplit" - }) - }); - let mut basenames = artifacts + 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.display_name.clone()) + .map(|artifact| artifact.artifact_id.clone()) + .collect::>(); + let mut basenames = pair + .iter() + .filter_map(|artifact| artifact.original_basename.clone()) .collect::>(); basenames.sort(); - basenames.dedup(); + 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 { - source_id: artifacts[0].source_id.clone(), + transaction_id: None, + source_id, producer_role: SccmRole::SiteServer, - direction: artifacts[0].direction.clone(), - target_site_code: bundle.topology.target_site_code.clone(), + direction, + origin_site_code, + target_site_code, basenames, reason_code: "coverageRotationSplit".to_owned(), }); } - requests.sort_by(request_order); - requests.dedup_by(|left, right| request_order(left, right).is_eq()); - requests + (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( @@ -1109,23 +1564,21 @@ fn request_order( 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, )) } - -fn artifact_prefix(artifact_id: &str) -> &str { - artifact_id - .rsplit_once('-') - .map_or(artifact_id, |(prefix, _)| prefix) -} diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 3989068c8..332317715 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -31,6 +31,75 @@ 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, @@ -252,6 +321,14 @@ fn normalized_topology_or_none( { return None; } + normalized.hierarchy_links.sort(); + if normalized + .hierarchy_links + .windows(2) + .any(|links| links[0] == links[1]) + { + return None; + } Some(normalized) } @@ -451,6 +528,8 @@ 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" @@ -458,6 +537,15 @@ pub struct SccmServerTopologyAssessment { 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 @@ -964,10 +1052,22 @@ fn normalize_topology( 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, @@ -975,6 +1075,34 @@ fn normalize_topology( }) } +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, @@ -2546,7 +2674,7 @@ fn safe_manifest_artifact_id(value: &str, synthetic_fixture: bool) -> bool { | "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:") } @@ -2561,6 +2689,8 @@ fn safe_source_id(value: &str, allow_unknown: bool, synthetic_fixture: bool) -> | "server-mp-iis" | "server-dp-distribution" | "server-dp-serve" + | "server-hierarchy-control" + | "server-hierarchy-transfer" | "server-sup-sync" | "server-sup-wsus" | "unknown-db-supplement" @@ -2644,7 +2774,7 @@ fn safe_lineage_id(value: &str, synthetic_fixture: bool) -> bool { | "sup-sync-lab" | "sup-wsus-health" | "unknown-db-export" - ); + ) || SYNTHETIC_HIERARCHY_LINEAGES.contains(&value); } opaque_sha256_handle(value, "cmtraceopen.lineage.sha256.v1:") } @@ -2705,7 +2835,7 @@ fn safe_path_fingerprint(value: &str, synthetic_fixture: bool) -> bool { | "synthetic:path:sup-wsus-health" | "synthetic:path:unsupported-db" | "synthetic:path:z-site" - ); + ) || SYNTHETIC_HIERARCHY_PATH_FINGERPRINTS.contains(&value); } opaque_sha256_handle(value, "cmtraceopen.path.sha256.v1:") } @@ -2830,7 +2960,11 @@ fn safe_optional_handle(value: Option<&str>, synthetic_fixture: bool, domain: &s return match domain { "host" => matches!( value, - "synthetic:host:mp-01" | "synthetic:host:site-01" | "synthetic:host:wsus-01" + "synthetic:host:mp-01" + | "synthetic:host:site-01" + | "synthetic:host:site-02" + | "synthetic:host:site-03" + | "synthetic:host:wsus-01" ), "subject" => { matches!( @@ -3219,6 +3353,17 @@ define_raw_server_wire! { "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, } } @@ -3371,7 +3516,16 @@ mod opaque_extension_boundary_tests { assert_eq!(RawServerPrivacy::KNOWN_FIELDS, ["synthetic", "rawPaths"]); assert_eq!( RawServerTopology::KNOWN_FIELDS, - ["captureHost", "siteCode", "rolesObserved"] + ["captureHost", "siteCode", "rolesObserved", "hierarchyLinks"] + ); + assert_eq!( + RawServerHierarchyLink::KNOWN_FIELDS, + [ + "originSiteCode", + "targetSiteCode", + "originHostHandle", + "targetHostHandle" + ] ); assert_eq!( RawWorkflowSubject::KNOWN_FIELDS, diff --git a/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs b/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs index 188899272..4c59e1923 100644 --- a/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs +++ b/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs @@ -1,15 +1,18 @@ +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, declared_server_source_catalog, SccmHierarchyArtifact, - SccmHierarchyBundle, SccmHierarchyDirection, SccmHierarchyProfileSelectionState, - SccmHierarchyState, SccmHierarchyTarget, SccmHierarchyTopology, SCCM_HIERARCHY_PROFILE_ID, + analyze_hierarchy_replication, assess_server_intake, declared_server_source_catalog, + SccmHierarchyProfileSelectionState, SccmHierarchyState, SccmHierarchyTimestampOrdering, + SccmServerArtifactPayload, SccmServerIntakeAssessment, SCCM_HIERARCHY_PROFILE_ID, SCCM_HIERARCHY_SOURCE_VERSION, }; use cmtraceopen_parser::sccm::{ - SccmArtifact, SccmConfidence, SccmCoverageState, SccmFindingClass, SccmRole, SccmRotation, + SccmConfidence, SccmCorrelationKeyKind, SccmFindingClass, SccmKeyConfidence, SccmRole, }; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; const SCENARIOS: &[&str] = &[ "absent-remote-source", @@ -25,117 +28,231 @@ const SCENARIOS: &[&str] = &[ "topology-mismatch", ]; +const FULL_OUTPUT_SHA256: &[(&str, &str)] = &[ + ( + "absent-remote-source", + "31878579ceeb0d6f46ead20f08195b39f01bb4baeba25618403506fdc9623831", + ), + ( + "backlog-retry", + "5f5a7ebbb05dc6c83084c9bd7727bd7309267f3d868e2f9899a9bf8b2934b5bb", + ), + ( + "clock-offset-unknown", + "fec2262eb343a34c6c625e8133f2ef6d56b411135b0e41aac1a8dc93fc01c2d6", + ), + ( + "generic-site-token", + "03b82f7f80db1f0fe6a6a3738cef962545584d280002558468d03c67e290dcb5", + ), + ( + "healthy-link", + "cd3a558339e006cbd8003bc35c81dc94b6db90391302863772debe7e17cff39f", + ), + ( + "incomplete", + "43f1a50ac2818110176cb8e4db007e1cbbf3ef920665c956bf8452f80263d9a9", + ), + ( + "receiver-processing-failure", + "0a23e3e09d4bbf60f5c5f0bd20274821600783b229466f22e0fd1b84713c58b8", + ), + ( + "recovery", + "b110daef580952190bc1ed17363cbd2dff19367c7647c9e7425e5bc3bdaeae29", + ), + ( + "rotation-boundary", + "452c1efab5b79c76fb496e75d4ffa8716ed1f41afe47c13709872f4448eb5d42", + ), + ( + "sender-failure", + "399ddc00fd0c70e94d3f6b31e5476dbf92a02930d4ce3f5fe8724d683b984686", + ), + ( + "topology-mismatch", + "cd0bbb73af63157f91a9f703e2cf3f4812818a48c5a1ed0c0c489c0397c23ba9", + ), +]; + fn corpus_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/sccm/server/hierarchy_and_replication") } -fn load_bundle(scenario: &str) -> (SccmHierarchyBundle, Value) { +fn fixture_parts(scenario: &str) -> (Value, Vec, Value) { let root = corpus_root().join(scenario); - let manifest: Value = serde_json::from_str( + 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 output is readable"), + &std::fs::read_to_string(root.join("expected.json")).expect("expected is readable"), ) - .expect("expected output is valid JSON"); - let topology = &manifest["topology"]; - let additional_targets = topology["additionalTargets"] - .as_array() - .into_iter() - .flatten() - .map(|target| SccmHierarchyTarget { - site_code: required_str(target, "siteCode").to_owned(), - host_handle: required_str(target, "hostHandle").to_owned(), - }) - .collect(); - let artifacts = manifest["artifacts"] + .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 relative_path = artifact["relativePath"].as_str(); - let content = relative_path - .map(|relative_path| { - std::fs::read_to_string(root.join(relative_path)) - .expect("declared hierarchy payload is readable") - }) - .unwrap_or_default(); + 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" => SccmRotation::Current, - "loUnderscore" => SccmRotation::LoUnderscore, - value => panic!("unexpected hierarchy rotation {value}"), - }; - let coverage = match required_str(artifact, "captureState") { - "captured" => SccmCoverageState::Captured, - "absent" => SccmCoverageState::Absent, - "accessDenied" => SccmCoverageState::AccessDenied, - "capped" => SccmCoverageState::Capped, - "skipped" => SccmCoverageState::Skipped, - "unsupported" => SccmCoverageState::Unsupported, - "parseFailed" => SccmCoverageState::ParseFailed, - value => panic!("unexpected hierarchy coverage {value}"), + "current" => "current", + "loUnderscore" => "lo_", + value => panic!("unsupported hierarchy rotation {value}"), }; - let direction = match required_str(artifact, "direction") { - "origin" => SccmHierarchyDirection::Origin, - "target" => SccmHierarchyDirection::Target, - value => panic!("unexpected hierarchy direction {value}"), - }; - let producer_host_handle = required_str(artifact, "producerHostHandle").to_owned(); - SccmHierarchyArtifact { - artifact: SccmArtifact { - artifact_id: required_str(artifact, "artifactId").to_owned(), - display_name: required_str(artifact, "originalBasename").to_owned(), - original_path: None, - host: Some(producer_host_handle.clone()), - 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: artifact["encoding"].as_str().map(str::to_owned), + 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"), }, - source_id: required_str(artifact, "sourceId").to_owned(), - direction, - producer_host_handle, - rotation_lineage_id: required_str(&artifact["rotation"], "lineageId").to_owned(), - fragment_complete: artifact["rotation"]["fragmentComplete"] - .as_bool() - .unwrap_or(true), - content, + "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(); + .collect::>(); ( - SccmHierarchyBundle { - profile_id: SCCM_HIERARCHY_PROFILE_ID.to_owned(), - source_version: SCCM_HIERARCHY_SOURCE_VERSION.to_owned(), - topology: SccmHierarchyTopology { - origin_site_code: required_str(topology, "originSiteCode").to_owned(), - target_site_code: required_str(topology, "targetSiteCode").to_owned(), - origin_host_handle: required_str(topology, "originHostHandle").to_owned(), - target_host_handle: required_str(topology, "targetHostHandle").to_owned(), - additional_targets, + 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": 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_the_exported_production_analyzer() { +fn every_corpus_scenario_runs_through_canonical_intake_and_the_exported_analyzer() { for scenario in SCENARIOS { - let (bundle, expected) = load_bundle(scenario); - let analysis = analyze_hierarchy_replication(&bundle).expect("topology is accepted"); + 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!( @@ -143,236 +260,272 @@ fn every_corpus_scenario_runs_through_the_exported_production_analyzer() { 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), - expected["coverage"], - "coverage mismatch for {scenario}" + coverage_expectation(&expected, scenario), + "{scenario}" ); assert_eq!( transaction_projection(&analysis), transaction_expectation(&expected), - "transaction mismatch for {scenario}" + "{scenario}" ); assert_eq!( source_local_projection(&analysis), source_local_expectation(&expected), - "source-local mismatch for {scenario}" + "{scenario}" ); assert_eq!( request_projection(&analysis), expected["artifactRequests"], - "request mismatch for {scenario}" + "{scenario}" ); + assert!(analysis + .findings + .iter() + .all(|finding| finding.validate().is_ok())); - for transaction in &analysis.transactions { - assert_eq!(transaction.producer_role, SccmRole::SiteServer); - assert_eq!(transaction.source_version, SCCM_HIERARCHY_SOURCE_VERSION); - assert!( - transaction.last_successful_phase.is_some() - || transaction.state == SccmHierarchyState::Deferred - || transaction.state == SccmHierarchyState::Failed - ); - assert_eq!( - transaction.next_artifacts, - analysis - .artifact_requests - .iter() - .filter(|request| request.target_site_code == transaction.key.target_site_code) - .cloned() - .collect::>() - ); - assert!(transaction.observations.iter().all(|observation| { - !observation.evidence.is_empty() - && observation.evidence.iter().all(|reference| { - reference.line_start.is_some() && reference.line_end.is_some() - }) - })); + 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 = bundle.clone(); - reversed.artifacts.reverse(); + 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( - analyze_hierarchy_replication(&reversed).expect("reversed topology is accepted") - ) - .expect("reversed 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 framed_hierarchy_grammar_does_not_depend_on_the_synthetic_marker_or_profile_claim() { - let (mut bundle, _) = load_bundle("healthy-link"); - for artifact in &mut bundle.artifacts { - artifact.content = artifact - .content - .replace("SYNTHETIC FIXTURE; ", "") - .replace("; ProfileId=hierarchy-server-5.00.test-v1", ""); +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 analysis = analyze_hierarchy_replication(&bundle).expect("topology is accepted"); - assert_eq!(analysis.transactions.len(), 1); + 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!( - analysis.transactions[0].state, - SccmHierarchyState::Succeeded + arbitrary.extraction_profile.selection_state, + SccmHierarchyProfileSelectionState::Unavailable ); - assert_eq!(analysis.transactions[0].confidence, SccmConfidence::High); + assert!(arbitrary.transactions.is_empty()); } #[test] -fn arbitrary_profiles_and_source_versions_fail_closed() { - let (mut arbitrary_profile, _) = load_bundle("healthy-link"); - arbitrary_profile.profile_id = "caller-attested-profile".to_owned(); - let analysis = - analyze_hierarchy_replication(&arbitrary_profile).expect("topology remains valid"); - assert!(analysis.transactions.is_empty()); +fn registered_hierarchy_profile_owns_exact_keys_and_validated_failure_findings() { + 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!(analysis.findings.len(), 2); + assert!(analysis.findings.iter().all(|finding| { + finding.class == SccmFindingClass::ConfirmedFailure + && finding.severity == Severity::Error + && !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.extraction_profile.selection_state, - SccmHierarchyProfileSelectionState::Unavailable + analysis.transactions[0].timestamp_ordering, + SccmHierarchyTimestampOrdering::Usable ); - assert!(analysis - .source_local_observations - .iter() - .all(|observation| observation.reason_code == "unvalidatedProfile")); - let (mut unknown_version, _) = load_bundle("healthy-link"); - unknown_version.source_version = "5.00.UNKNOWN.0001".to_owned(); - let analysis = analyze_hierarchy_replication(&unknown_version).expect("topology remains valid"); - assert!(analysis.transactions.is_empty()); + 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.extraction_profile.selection_state, - SccmHierarchyProfileSelectionState::Unavailable + analysis.transactions[0].timestamp_ordering, + SccmHierarchyTimestampOrdering::Contradictory ); -} -#[test] -fn exact_message_and_link_kinds_reject_content_like_or_partial_values() { - let (mut bundle, _) = load_bundle("sender-failure"); - bundle.artifacts[0].content = bundle.artifacts[0] - .content - .replace("MessageId=msg-send-chd", "MessageId=content-chd") - .replace("LinkId=link-lab-sec", "LinkId=content-lab-sec"); - let analysis = analyze_hierarchy_replication(&bundle).expect("topology is accepted"); - assert!(analysis.transactions.is_empty()); - assert!(analysis - .source_local_observations - .iter() - .all(|observation| !observation.correlation_eligible)); + 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 terminal_and_remote_causality_are_conservative() { - let (sender, _) = load_bundle("sender-failure"); - let sender = analyze_hierarchy_replication(&sender).expect("topology is accepted"); - assert!(sender.transactions.iter().all(|transaction| { - transaction.state == SccmHierarchyState::Failed - && transaction.finding_class == Some(SccmFindingClass::ConfirmedFailure) - && !transaction.correlation_eligible - })); - - let (mut receiver, _) = load_bundle("receiver-processing-failure"); - let despool = receiver - .artifacts - .iter_mut() - .find(|artifact| artifact.artifact.display_name == "despool.log") - .expect("despool artifact exists"); - despool.content = despool - .content - .lines() - .next() - .expect("receiver fact exists") - .replace( - "Disposition=succeeded; Terminal=false", - "Disposition=failed; Terminal=true", - ); - let receiver = analyze_hierarchy_replication(&receiver).expect("topology is accepted"); - assert_eq!(receiver.transactions[0].state, SccmHierarchyState::Failed); - assert_eq!(receiver.transactions[0].confidence, SccmConfidence::High); +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!( - receiver.transactions[0].finding_class, - Some(SccmFindingClass::ConfirmedFailure) + 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"); - for scenario in ["healthy-link", "receiver-processing-failure", "recovery"] { - let (bundle, _) = load_bundle(scenario); - let analysis = analyze_hierarchy_replication(&bundle).expect("topology is accepted"); - assert!(analysis - .transactions - .iter() - .all(|transaction| transaction.correlation_eligible)); - } + 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 contradiction_malformed_grammar_and_topology_mutations_fail_closed() { - let (mut contradictory, _) = load_bundle("healthy-link"); - let sender = contradictory - .artifacts - .iter_mut() - .find(|artifact| artifact.artifact.display_name == "sender.log") - .expect("sender artifact exists"); - let failed = sender - .content - .replace( - "Disposition=succeeded; Terminal=false", - "Disposition=failed; Terminal=true", - ) - .replace("15:03:02.000+000", "15:03:02.500+000"); - sender.content = format!("{}\n{failed}", sender.content); - let analysis = analyze_hierarchy_replication(&contradictory).expect("topology is accepted"); +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].state, - SccmHierarchyState::Contradictory + analysis.transactions[0].coverage_gap_artifact_ids, + ["absent-02-despool"] ); - assert_eq!(analysis.transactions[0].confidence, SccmConfidence::Low); - assert!(!analysis.transactions[0].correlation_eligible); - - let (mut malformed, _) = load_bundle("sender-failure"); - malformed.artifacts[0].content = malformed.artifacts[0] - .content - .replace("MessageId=msg-", "MessageId=msg/"); - let analysis = analyze_hierarchy_replication(&malformed).expect("topology is accepted"); - assert!(analysis.transactions.is_empty()); - assert!(analysis - .source_local_observations + assert!(analysis.transactions[0] + .next_artifacts .iter() - .all(|observation| !observation.correlation_eligible)); - - let (mut wrong_role, _) = load_bundle("healthy-link"); - for artifact in &mut wrong_role.artifacts { - artifact.artifact.role = SccmRole::ManagementPoint; - } - assert!(analyze_hierarchy_replication(&wrong_role) - .expect("topology is accepted") - .transactions - .is_empty()); - - let (mut wrong_host, _) = load_bundle("receiver-processing-failure"); - wrong_host.artifacts[1].producer_host_handle = wrong_host.topology.origin_host_handle.clone(); - let analysis = analyze_hierarchy_replication(&wrong_host).expect("topology is accepted"); - assert!(analysis.transactions.iter().all(|transaction| { - transaction.state == SccmHierarchyState::Incomplete && !transaction.correlation_eligible + .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" })); +} - let (mut artifact_versions, _) = load_bundle("healthy-link"); - for artifact in &mut artifact_versions.artifacts { - artifact.artifact.configmgr_version = Some("5.00.UNKNOWN.0001".to_owned()); - } - let analysis = analyze_hierarchy_replication(&artifact_versions).expect("topology is accepted"); - assert!(analysis.transactions.is_empty()); +#[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() - .all(|observation| observation.reason_code == "unvalidatedProfile")); + .any(|observation| { + observation.reason_code == "topologyOrGrammarMismatch" + && observation.observation_id.contains(":1-1:") + })); } #[test] @@ -391,6 +544,16 @@ fn hierarchy_sources_live_in_the_canonical_server_windows_catalog() { 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 { @@ -408,6 +571,16 @@ fn coverage_projection( ) } +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 { @@ -418,7 +591,14 @@ fn transaction_projection( .map(|transaction| { json!({ "transactionId": transaction.transaction_id, - "key": transaction.key, + "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, @@ -461,7 +641,7 @@ fn transaction_expectation(expected: &Value) -> Value { "confidence": transaction["confidence"], "confidenceCeiling": transaction["confidenceCeiling"], "coverageGapArtifactIds": transaction["coverageGapArtifactIds"], - "observations": transaction["observations"].as_array().expect("observations are an array").iter().map(|observation| json!({ + "observations": transaction["observations"].as_array().expect("observations").iter().map(|observation| json!({ "phase": observation["phase"], "disposition": observation["disposition"], "terminal": observation["terminal"], @@ -544,5 +724,20 @@ fn source_local_expectation(expected: &Value) -> Value { fn request_projection( analysis: &cmtraceopen_parser::sccm::server::windows::SccmHierarchyAnalysis, ) -> Value { - serde_json::to_value(&analysis.artifact_requests).expect("requests serialize") + 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(), + ) } From b8f247c884574a10c99ce1c2e9823b90b7d31c5e Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 03:50:53 -0400 Subject: [PATCH 401/422] fix(sccm): request omitted hierarchy target evidence --- .../src/sccm/server/windows/hierarchy.rs | 49 ++++++++++++++++- .../tests/sccm_hierarchy_reducer.rs | 54 +++++++++++++++++-- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs index be3d8423e..ff90d96af 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs @@ -611,7 +611,8 @@ pub fn analyze_hierarchy_replication( } else { SccmHierarchyRemoteCausality::NotEstablished }; - let next_artifacts = artifact_requests + let topology = topology_link(intake, &candidate.key)?; + let mut next_artifacts = artifact_requests .iter() .filter(|request| { request.origin_site_code == candidate.key.origin_site_code @@ -623,7 +624,19 @@ pub fn analyze_hierarchy_replication( request }) .collect::>(); - let topology = topology_link(intake, &candidate.key)?; + if state == SccmHierarchyState::Incomplete + && last_successful_phase + .as_ref() + .is_some_and(|phase| phase.rank() >= SccmHierarchyPhase::Send.rank()) + { + next_artifacts.extend(missing_target_side_requests( + &artifacts, + topology, + &transaction_id, + )); + } + 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, @@ -1231,6 +1244,38 @@ fn coverage_requests( requests } +fn missing_target_side_requests( + artifacts: &[&SccmServerArtifactAssessment], + topology: &SccmServerHierarchyLinkTopology, + transaction_id: &str, +) -> Vec { + let target_side_declared = artifacts.iter().any(|artifact| { + artifact_direction(artifact) == Some(SccmHierarchyDirection::Target) + && artifact.producer_host_handle.as_deref() + == Some(topology.target_host_handle.as_str()) + }); + if target_side_declared { + return Vec::new(); + } + + [ + ("server-hierarchy-transfer", "despool.log"), + ("server-hierarchy-control", "rcmctrl.log"), + ] + .into_iter() + .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: "missingTargetReceiveProcessApply".to_owned(), + }) + .collect() +} + fn exact_artifact_scope( intake: &SccmServerIntakeAssessment, artifact: &SccmServerArtifactAssessment, diff --git a/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs b/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs index 4c59e1923..b6d56ceca 100644 --- a/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs +++ b/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs @@ -4,9 +4,9 @@ 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, - SccmHierarchyProfileSelectionState, SccmHierarchyState, SccmHierarchyTimestampOrdering, - SccmServerArtifactPayload, SccmServerIntakeAssessment, SCCM_HIERARCHY_PROFILE_ID, - SCCM_HIERARCHY_SOURCE_VERSION, + SccmHierarchyDirection, SccmHierarchyProfileSelectionState, SccmHierarchyRemoteCausality, + SccmHierarchyState, SccmHierarchyTimestampOrdering, SccmServerArtifactPayload, + SccmServerIntakeAssessment, SCCM_HIERARCHY_PROFILE_ID, SCCM_HIERARCHY_SOURCE_VERSION, }; use cmtraceopen_parser::sccm::{ SccmConfidence, SccmCorrelationKeyKind, SccmFindingClass, SccmKeyConfidence, SccmRole, @@ -509,6 +509,54 @@ fn gaps_and_requests_are_transaction_and_exact_topology_scoped() { })); } +#[test] +fn omitted_target_side_emits_transaction_scoped_receive_process_apply_requests() { + let (mut manifest, mut payloads, _) = fixture_parts("healthy-link"); + let target_ids = manifest["artifacts"] + .as_array() + .expect("artifacts") + .iter() + .filter(|artifact| { + matches!( + required_str(artifact, "originalBasename"), + "despool.log" | "rcmctrl.log" + ) + }) + .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)); + + let analysis = analyze_hierarchy_replication(&assess(&manifest, &payloads)).expect("sealed"); + 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(), 2); + 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 = transaction + .next_artifacts + .iter() + .flat_map(|request| request.basenames.iter().map(String::as_str)) + .collect::>(); + assert_eq!(requested, BTreeSet::from(["despool.log", "rcmctrl.log"])); + assert_eq!(analysis.artifact_requests, transaction.next_artifacts); +} + #[test] fn malformed_grammar_is_retained_source_locally_without_promoting_keys() { let (mut manifest, mut payloads, _) = fixture_parts("sender-failure"); From 0118a6be36154950f5ca5061687a245222f260bd Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 04:15:20 -0400 Subject: [PATCH 402/422] fix(sccm): require each hierarchy target source --- .../src/sccm/server/windows/hierarchy.rs | 41 ++--- .../absent-remote-source/expected.json | 5 +- .../backlog-retry/expected.json | 7 +- .../clock-offset-unknown/expected.json | 5 +- .../receiver-processing-failure/expected.json | 4 +- .../recovery/expected.json | 4 +- .../sender-failure/expected.json | 11 +- .../tests/sccm_hierarchy_reducer.rs | 70 ++++--- ...rarchy_and_replication_fixture_contract.rs | 171 ++++++++++++++---- 9 files changed, 228 insertions(+), 90 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs index ff90d96af..36404257f 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs @@ -534,6 +534,10 @@ pub fn analyze_hierarchy_replication( 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 gaps = missing_required_artifacts(intake, &artifacts, &candidate.key); if candidate.observations.len() == 1 && !terminal_failure @@ -559,7 +563,7 @@ pub fn analyze_hierarchy_replication( SccmHierarchyState::Contradictory, Some(SccmFindingClass::Symptom), ) - } else if unusable_time || !gaps.is_empty() { + } else if unusable_time || !gaps.is_empty() || !missing_target_requests.is_empty() { ( SccmHierarchyState::Incomplete, Some(SccmFindingClass::InsufficientEvidence), @@ -593,7 +597,6 @@ pub fn analyze_hierarchy_replication( SccmConfidence::Low } }; - let transaction_id = transaction_id(&candidate.key); let last_successful_phase = candidate .observations .iter() @@ -605,13 +608,13 @@ pub fn analyze_hierarchy_replication( 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 topology = topology_link(intake, &candidate.key)?; let mut next_artifacts = artifact_requests .iter() .filter(|request| { @@ -624,17 +627,7 @@ pub fn analyze_hierarchy_replication( request }) .collect::>(); - if state == SccmHierarchyState::Incomplete - && last_successful_phase - .as_ref() - .is_some_and(|phase| phase.rank() >= SccmHierarchyPhase::Send.rank()) - { - next_artifacts.extend(missing_target_side_requests( - &artifacts, - topology, - &transaction_id, - )); - } + 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()); @@ -1244,25 +1237,25 @@ fn coverage_requests( requests } -fn missing_target_side_requests( +fn missing_target_source_requests( artifacts: &[&SccmServerArtifactAssessment], topology: &SccmServerHierarchyLinkTopology, transaction_id: &str, ) -> Vec { - let target_side_declared = artifacts.iter().any(|artifact| { - artifact_direction(artifact) == Some(SccmHierarchyDirection::Target) - && artifact.producer_host_handle.as_deref() - == Some(topology.target_host_handle.as_str()) - }); - if target_side_declared { - return Vec::new(); - } - [ ("server-hierarchy-transfer", "despool.log"), ("server-hierarchy-control", "rcmctrl.log"), ] .into_iter() + .filter(|(source_id, basename)| { + !artifacts.iter().any(|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(), 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 index 556827c9c..441f8964a 100644 --- 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 @@ -15,7 +15,10 @@ "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-transfer","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["despool.log"],"reasonCode":"coverageAbsent"}], + "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":"coverageAbsent"} + ], "crossSideCausalClaims": [], "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} } 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 index 88a164117..c0fac1777 100644 --- 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 @@ -10,12 +10,15 @@ "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":"deferred","classification":"blockedOrDeferred","confidence":"medium","confidenceCeiling":"medium", + "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": [], + "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/clock-offset-unknown/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/clock-offset-unknown/expected.json index 6741886b2..7b8d555f1 100644 --- 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 @@ -18,7 +18,10 @@ ] }], "sourceLocalObservations": [], - "artifactRequests": [{"sourceId":"server-hierarchy-transfer","producerRole":"siteServer","direction":"both","targetSiteCode":"CHD","basenames":["despool.log","sender.log"],"reasonCode":"invalidOffset"}], + "artifactRequests": [ + {"sourceId":"server-hierarchy-control","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["rcmctrl.log"],"reasonCode":"missingTargetReceiveProcessApply"}, + {"sourceId":"server-hierarchy-transfer","producerRole":"siteServer","direction":"both","targetSiteCode":"CHD","basenames":["despool.log","sender.log"],"reasonCode":"invalidOffset"} + ], "crossSideCausalClaims": [], "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} } 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 index de2f137e9..e6f092fb4 100644 --- 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 @@ -10,7 +10,7 @@ "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":"failed","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high", + "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}]}, @@ -19,7 +19,7 @@ ] }], "sourceLocalObservations": [], - "artifactRequests": [], + "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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/recovery/expected.json index 4fa90eb15..7b78d488d 100644 --- 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 @@ -10,7 +10,7 @@ "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":"recovered","classification":"success","confidence":"high","confidenceCeiling":"high", + "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}]}, @@ -21,7 +21,7 @@ ] }], "sourceLocalObservations": [], - "artifactRequests": [], + "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/sender-failure/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/sender-failure/expected.json index 5103e071b..27727275c 100644 --- 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 @@ -11,19 +11,24 @@ "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":"failed","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","coverageGapArtifactIds":[], + "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":"failed","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","coverageGapArtifactIds":[], + "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": [], + "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/sccm_hierarchy_reducer.rs b/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs index b6d56ceca..66c9a04c0 100644 --- a/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs +++ b/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs @@ -31,15 +31,15 @@ const SCENARIOS: &[&str] = &[ const FULL_OUTPUT_SHA256: &[(&str, &str)] = &[ ( "absent-remote-source", - "31878579ceeb0d6f46ead20f08195b39f01bb4baeba25618403506fdc9623831", + "2ecf2bbce2368a23489ca4f81b772271ceb7ac743343daf668bf2c618c80551a", ), ( "backlog-retry", - "5f5a7ebbb05dc6c83084c9bd7727bd7309267f3d868e2f9899a9bf8b2934b5bb", + "e63b67202b6e5a0aa9179000ad72d3215cae3d5b8bfc894fea2f8c9c7bde36a9", ), ( "clock-offset-unknown", - "fec2262eb343a34c6c625e8133f2ef6d56b411135b0e41aac1a8dc93fc01c2d6", + "0c14d94209cc7765303b429353732990ef775aef69f994c5894796f5cd0a25b8", ), ( "generic-site-token", @@ -55,11 +55,11 @@ const FULL_OUTPUT_SHA256: &[(&str, &str)] = &[ ), ( "receiver-processing-failure", - "0a23e3e09d4bbf60f5c5f0bd20274821600783b229466f22e0fd1b84713c58b8", + "5785052b51919023838db1bb0d282e234002f9eb05609dbf8d74e188e0f04fd7", ), ( "recovery", - "b110daef580952190bc1ed17363cbd2dff19367c7647c9e7425e5bc3bdaeae29", + "d2d7035ae31dc35ed07b517bc8ba1f08159460316fba38d8267932067b7969d9", ), ( "rotation-boundary", @@ -67,7 +67,7 @@ const FULL_OUTPUT_SHA256: &[(&str, &str)] = &[ ), ( "sender-failure", - "399ddc00fd0c70e94d3f6b31e5476dbf92a02930d4ce3f5fe8724d683b984686", + "87934ae0c81f5bde7d075fc11f75d982f2f81c02dd045df52ff8c24b51bc88c3", ), ( "topology-mismatch", @@ -391,7 +391,7 @@ fn hierarchy_authority_is_private_canonical_intake_and_all_provenance_is_sealed( } #[test] -fn registered_hierarchy_profile_owns_exact_keys_and_validated_failure_findings() { +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); @@ -405,12 +405,19 @@ fn registered_hierarchy_profile_owns_exact_keys_and_validated_failure_findings() 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::ConfirmedFailure - && finding.severity == Severity::Error - && !finding.terminal_evidence.is_empty() + finding.class == SccmFindingClass::Symptom + && finding.severity == Severity::Warning + && finding.terminal_evidence.is_empty() && finding.validate().is_ok() })); } @@ -509,19 +516,17 @@ fn gaps_and_requests_are_transaction_and_exact_topology_scoped() { })); } -#[test] -fn omitted_target_side_emits_transaction_scoped_receive_process_apply_requests() { +fn assert_missing_target_sources(missing: &[(&str, &str)]) { let (mut manifest, mut payloads, _) = fixture_parts("healthy-link"); + let missing_basenames = missing + .iter() + .map(|(_, basename)| *basename) + .collect::>(); let target_ids = manifest["artifacts"] .as_array() .expect("artifacts") .iter() - .filter(|artifact| { - matches!( - required_str(artifact, "originalBasename"), - "despool.log" | "rcmctrl.log" - ) - }) + .filter(|artifact| missing_basenames.contains(required_str(artifact, "originalBasename"))) .map(|artifact| required_str(artifact, "artifactId").to_owned()) .collect::>(); manifest["artifacts"] @@ -540,7 +545,7 @@ fn omitted_target_side_emits_transaction_scoped_receive_process_apply_requests() SccmHierarchyRemoteCausality::NotEstablished ); - assert_eq!(transaction.next_artifacts.len(), 2); + 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 @@ -548,15 +553,36 @@ fn omitted_target_side_emits_transaction_scoped_receive_process_apply_requests() && request.target_site_code == "CHD" && request.reason_code == "missingTargetReceiveProcessApply" })); - let requested = transaction + let requested_sources = transaction .next_artifacts .iter() - .flat_map(|request| request.basenames.iter().map(String::as_str)) + .map(|request| { + assert_eq!(request.basenames.len(), 1); + (request.source_id.as_str(), request.basenames[0].as_str()) + }) .collect::>(); - assert_eq!(requested, BTreeSet::from(["despool.log", "rcmctrl.log"])); + 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 malformed_grammar_is_retained_source_locally_without_promoting_keys() { let (mut manifest, mut payloads, _) = fixture_parts("sender-failure"); 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 index 31d33c0c1..2b3abdaa7 100644 --- 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 @@ -761,7 +761,7 @@ fn observation_matches_record( && record_matches_topology(manifest, artifact, &fields) } -fn transaction_semantics_are_coherent(transaction: &Value) -> bool { +fn transaction_semantics_are_coherent(transaction: &Value, manifest: &Value) -> bool { let Some(observations) = transaction["observations"].as_array() else { return false; }; @@ -787,19 +787,23 @@ fn transaction_semantics_are_coherent(transaction: &Value) -> bool { return false; } - let (state, classification) = if transaction["timestampOrdering"] != "usable" { - ("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") - }; + 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 } @@ -1092,9 +1096,74 @@ fn invalid_offset_request_basis(scenario: &str, manifest: &Value) -> Option 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) + }) +} + +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_declared(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_declared(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"] { @@ -1113,6 +1182,7 @@ fn derived_artifact_requests( reason_code: "invalidOffset".to_owned(), }); } + requests.extend(missing_target_source_requests(manifest, expected)); requests } @@ -1143,9 +1213,9 @@ fn artifact_request_failures(scenario: &str, manifest: &Value, expected: &Value) .iter() .map(|request| { ( + request["targetSiteCode"].as_str(), request["sourceId"].as_str(), request["direction"].as_str(), - request["targetSiteCode"].as_str(), request["reasonCode"].as_str(), ) }) @@ -1217,6 +1287,13 @@ fn artifact_request_failures(scenario: &str, manifest: &Value, expected: &Value) 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 { @@ -1231,7 +1308,7 @@ fn artifact_request_failures(scenario: &str, manifest: &Value, expected: &Value) .filter_map(declared_artifact_request) .collect::>(); if declared_requests.len() != requests.len() - || declared_requests != derived_artifact_requests(scenario, manifest) + || declared_requests != derived_artifact_requests(scenario, manifest, expected) { failures.push(format!( "{scenario}: artifact requests are not the complete derived bounded set" @@ -1692,7 +1769,7 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val { failures.push("transaction key is outside declared topology".to_owned()); } - if !transaction_semantics_are_coherent(transaction) { + if !transaction_semantics_are_coherent(transaction, manifest) { failures .push("transaction state/classification is not derived from its facts".to_owned()); } @@ -1746,10 +1823,14 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val .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" @@ -1757,6 +1838,7 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val && transaction["terminalEvidence"] == false && gap_ids.is_empty() && has_retrying_fact + && !target_source_missing { "medium" } else { @@ -1775,7 +1857,8 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val || transaction["topologyCompatibility"] != "exact" || transaction["timestampOrdering"] != "usable" || transaction["terminalEvidence"] != true - || !gap_ids.is_empty()) + || !gap_ids.is_empty() + || target_source_missing) { failures .push("high confidence bypasses topology/time/terminal/coverage gates".to_owned()); @@ -2644,21 +2727,28 @@ fn hierarchy_transactions_require_exact_keys_topology_time_and_citations() { }) .collect::>(); let expected_outcome: &[(Option<&str>, Option<&str>, Option<&str>)] = match *scenario { - "absent-remote-source" | "clock-offset-unknown" => &[( + "absent-remote-source" + | "backlog-retry" + | "clock-offset-unknown" + | "receiver-processing-failure" + | "recovery" => &[( Some("incomplete"), Some("insufficientEvidence"), Some("low"), )], - "backlog-retry" => &[(Some("deferred"), Some("blockedOrDeferred"), Some("medium"))], "healthy-link" => &[(Some("succeeded"), Some("success"), Some("high"))], "incomplete" | "rotation-boundary" | "topology-mismatch" => &[], - "receiver-processing-failure" => { - &[(Some("failed"), Some("confirmedFailure"), Some("high"))] - } - "recovery" => &[(Some("recovered"), Some("success"), Some("high"))], "sender-failure" => &[ - (Some("failed"), Some("confirmedFailure"), Some("high")), - (Some("failed"), Some("confirmedFailure"), Some("high")), + ( + Some("incomplete"), + Some("insufficientEvidence"), + Some("low"), + ), + ( + Some("incomplete"), + Some("insufficientEvidence"), + Some("low"), + ), ], _ => &[], }; @@ -2708,10 +2798,21 @@ fn hierarchy_gaps_requests_and_source_local_controls_are_bounded() { .filter_map(|request| request["reasonCode"].as_str()) .collect::>(); let expected_reasons: &[&str] = match *scenario { - "absent-remote-source" => &["coverageAbsent"], - "clock-offset-unknown" => &["invalidOffset"], + "absent-remote-source" => &["missingTargetReceiveProcessApply", "coverageAbsent"], + "backlog-retry" => &[ + "missingTargetReceiveProcessApply", + "missingTargetReceiveProcessApply", + ], + "clock-offset-unknown" => &["missingTargetReceiveProcessApply", "invalidOffset"], "incomplete" => &["coverageCapped"], + "receiver-processing-failure" | "recovery" => &["missingTargetReceiveProcessApply"], "rotation-boundary" => &["coverageRotationSplit"], + "sender-failure" => &[ + "missingTargetReceiveProcessApply", + "missingTargetReceiveProcessApply", + "missingTargetReceiveProcessApply", + "missingTargetReceiveProcessApply", + ], _ => &[], }; if request_reason_codes != expected_reasons { @@ -2795,9 +2896,10 @@ fn hierarchy_schema_and_identity_mutations_fail_closed() { .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!( - identity_and_schema_failures(scenario, &manifest, &expected).is_empty(), - "{scenario}: committed schema is invalid" + failures.is_empty(), + "{scenario}: committed schema is invalid: {failures:?}" ); } @@ -3090,7 +3192,7 @@ fn hierarchy_artifact_request_mutations_fail_closed() { let mut accepted = Vec::new(); for (label, field, value) in mutations { let mut mutated = expected.clone(); - mutated["artifactRequests"][0][field] = value; + mutated["artifactRequests"][1][field] = value; if artifact_request_failures("clock-offset-unknown", &manifest, &mutated).is_empty() || identity_and_schema_failures("clock-offset-unknown", &manifest, &mutated).is_empty() { @@ -3496,8 +3598,11 @@ fn hierarchy_coderabbit_878a051_control_requests_include_target_rcmctrl() { 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"); - expected["artifactRequests"][0]["sourceId"] = serde_json::json!("server-hierarchy-control"); - expected["artifactRequests"][0]["basenames"] = serde_json::json!(["rcmctrl.log"]); + expected["artifactRequests"][0]["reasonCode"] = serde_json::json!("coverageAbsent"); + expected["artifactRequests"][1]["sourceId"] = serde_json::json!("server-hierarchy-transfer"); + expected["artifactRequests"][1]["basenames"] = serde_json::json!(["despool.log"]); + expected["artifactRequests"][1]["reasonCode"] = + serde_json::json!("missingTargetReceiveProcessApply"); let failures = artifact_request_failures("absent-remote-source", &manifest, &expected); assert!( From ed7aa254e80ea9c9bb09100efeb900931dea0fda Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 04:46:17 -0400 Subject: [PATCH 403/422] fix(sccm): prioritize missing hierarchy targets --- .../src/sccm/server/windows/hierarchy.rs | 21 ++- .../clock-offset-unknown/expected.json | 3 +- .../topology-mismatch/expected.json | 32 +++- .../tests/sccm_hierarchy_reducer.rs | 138 +++++++++++++++++- ...rarchy_and_replication_fixture_contract.rs | 72 +++++---- 5 files changed, 226 insertions(+), 40 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs index 36404257f..fc5ad7d32 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs @@ -27,6 +27,7 @@ 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 @@ -538,12 +539,14 @@ pub fn analyze_hierarchy_replication( 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 { @@ -558,12 +561,17 @@ pub fn analyze_hierarchy_replication( return None; } let unusable_time = ordering != SccmHierarchyTimestampOrdering::Usable; - let (state, finding_class) = if contradictory { + 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() || !missing_target_requests.is_empty() { + } else if unusable_time || !gaps.is_empty() { ( SccmHierarchyState::Incomplete, Some(SccmFindingClass::InsufficientEvidence), @@ -668,7 +676,12 @@ pub fn analyze_hierarchy_replication( .collect::>(); transactions.sort_by(|left, right| left.transaction_id.cmp(&right.transaction_id)); for transaction in &mut transactions { - if transaction.timestamp_ordering == SccmHierarchyTimestampOrdering::Usable { + 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); @@ -1264,7 +1277,7 @@ fn missing_target_source_requests( origin_site_code: topology.origin_site_code.clone(), target_site_code: topology.target_site_code.clone(), basenames: vec![basename.to_owned()], - reason_code: "missingTargetReceiveProcessApply".to_owned(), + reason_code: MISSING_TARGET_REASON_CODE.to_owned(), }) .collect() } 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 index 7b8d555f1..ca97399d8 100644 --- 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 @@ -19,8 +19,7 @@ }], "sourceLocalObservations": [], "artifactRequests": [ - {"sourceId":"server-hierarchy-control","producerRole":"siteServer","direction":"target","targetSiteCode":"CHD","basenames":["rcmctrl.log"],"reasonCode":"missingTargetReceiveProcessApply"}, - {"sourceId":"server-hierarchy-transfer","producerRole":"siteServer","direction":"both","targetSiteCode":"CHD","basenames":["despool.log","sender.log"],"reasonCode":"invalidOffset"} + {"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/topology-mismatch/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/hierarchy_and_replication/topology-mismatch/expected.json index 92d6c6c77..8feecd461 100644 --- 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 @@ -6,12 +6,34 @@ "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": [], - "sourceLocalObservations": [ - {"observationId":"mismatch-01-origin","classification":"topologyMismatch","confidence":"low","correlationEligible":false,"artifactIds":["mismatch-01-sender"],"evidence":[{"artifactId":"mismatch-01-sender","startLine":1,"endLine":1}]}, - {"observationId":"mismatch-02-target","classification":"topologyMismatch","confidence":"low","correlationEligible":false,"artifactIds":["mismatch-02-despool"],"evidence":[{"artifactId":"mismatch-02-despool","startLine":1,"endLine":1}]} + "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"} ], - "artifactRequests": [], "crossSideCausalClaims": [], "correlationHandoff": {"issue":"#333","performed":false,"timeOnlyEligible":false} } diff --git a/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs b/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs index 66c9a04c0..242f1c355 100644 --- a/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs +++ b/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs @@ -39,7 +39,7 @@ const FULL_OUTPUT_SHA256: &[(&str, &str)] = &[ ), ( "clock-offset-unknown", - "0c14d94209cc7765303b429353732990ef775aef69f994c5894796f5cd0a25b8", + "da02ddec04085e5c729365212b2c741d92fc1ec025bfe7bb01ed481e93f6fa71", ), ( "generic-site-token", @@ -71,7 +71,7 @@ const FULL_OUTPUT_SHA256: &[(&str, &str)] = &[ ), ( "topology-mismatch", - "cd0bbb73af63157f91a9f703e2cf3f4812818a48c5a1ed0c0c489c0397c23ba9", + "6747a6b9e63c88ff544be788bf0ce1c51dfa2dfb04dedf45cc67dcb76efc6304", ), ]; @@ -518,6 +518,17 @@ fn gaps_and_requests_are_transaction_and_exact_topology_scoped() { 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) @@ -534,8 +545,41 @@ fn assert_missing_target_sources(missing: &[(&str, &str)]) { .expect("artifacts") .retain(|artifact| !target_ids.contains(required_str(artifact, "artifactId"))); payloads.retain(|payload| !target_ids.contains(&payload.manifest_artifact_id)); +} - let analysis = analyze_hierarchy_replication(&assess(&manifest, &payloads)).expect("sealed"); +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); @@ -583,6 +627,94 @@ fn omitted_both_target_sources_emit_both_requests() { ]); } +#[test] +fn missing_target_gate_precedes_cross_artifact_contradiction_and_terminal_success() { + 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 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 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"); 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 index 2b3abdaa7..237c8f670 100644 --- 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 @@ -826,9 +826,11 @@ fn expected_transaction_ids(scenario: &str) -> Option<&'static [&'static str]> { "hierarchy:msg-send-chd:LAB:CHD:link-lab-chd", "hierarchy:msg-send-sec:LAB:SEC:link-lab-sec", ]), - "generic-site-token" | "incomplete" | "rotation-boundary" | "topology-mismatch" => { - Some(&[]) - } + "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, } } @@ -860,9 +862,8 @@ fn expected_observation_ids(scenario: &str) -> Option<&'static [&'static str]> { "recovery-05-terminal", ]), "sender-failure" => Some(&["sender-01-chd-failure", "sender-02-sec-failure"]), - "generic-site-token" | "incomplete" | "rotation-boundary" | "topology-mismatch" => { - Some(&[]) - } + "topology-mismatch" => Some(&["mismatch-01-origin", "mismatch-02-target"]), + "generic-site-token" | "incomplete" | "rotation-boundary" => Some(&[]), _ => None, } } @@ -871,7 +872,6 @@ fn expected_source_local_ids(scenario: &str) -> Option<&'static [&'static str]> match scenario { "incomplete" => Some(&["incomplete-01-fragment"]), "rotation-boundary" => Some(&["rotation-01-split"]), - "topology-mismatch" => Some(&["mismatch-01-origin", "mismatch-02-target"]), "absent-remote-source" | "backlog-retry" | "clock-offset-unknown" @@ -879,7 +879,8 @@ fn expected_source_local_ids(scenario: &str) -> Option<&'static [&'static str]> | "healthy-link" | "receiver-processing-failure" | "recovery" - | "sender-failure" => Some(&[]), + | "sender-failure" + | "topology-mismatch" => Some(&[]), _ => None, } } @@ -1176,13 +1177,16 @@ fn derived_artifact_requests( } } } - if let Some(basis) = invalid_offset_request_basis(scenario, manifest) { - requests.insert(ArtifactRequestContract { - basis, - reason_code: "invalidOffset".to_owned(), - }); + let missing_target_requests = missing_target_source_requests(manifest, expected); + 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_source_requests(manifest, expected)); + requests.extend(missing_target_requests); requests } @@ -2737,7 +2741,19 @@ fn hierarchy_transactions_require_exact_keys_topology_time_and_citations() { Some("low"), )], "healthy-link" => &[(Some("succeeded"), Some("success"), Some("high"))], - "incomplete" | "rotation-boundary" | "topology-mismatch" => &[], + "incomplete" | "rotation-boundary" => &[], + "topology-mismatch" => &[ + ( + Some("incomplete"), + Some("insufficientEvidence"), + Some("low"), + ), + ( + Some("incomplete"), + Some("insufficientEvidence"), + Some("low"), + ), + ], "sender-failure" => &[ ( Some("incomplete"), @@ -2803,7 +2819,7 @@ fn hierarchy_gaps_requests_and_source_local_controls_are_bounded() { "missingTargetReceiveProcessApply", "missingTargetReceiveProcessApply", ], - "clock-offset-unknown" => &["missingTargetReceiveProcessApply", "invalidOffset"], + "clock-offset-unknown" => &["missingTargetReceiveProcessApply"], "incomplete" => &["coverageCapped"], "receiver-processing-failure" | "recovery" => &["missingTargetReceiveProcessApply"], "rotation-boundary" => &["coverageRotationSplit"], @@ -2813,6 +2829,11 @@ fn hierarchy_gaps_requests_and_source_local_controls_are_bounded() { "missingTargetReceiveProcessApply", "missingTargetReceiveProcessApply", ], + "topology-mismatch" => &[ + "missingTargetReceiveProcessApply", + "missingTargetReceiveProcessApply", + "missingTargetReceiveProcessApply", + ], _ => &[], }; if request_reason_codes != expected_reasons { @@ -2829,7 +2850,6 @@ fn hierarchy_gaps_requests_and_source_local_controls_are_bounded() { let expected_classes: &[&str] = match *scenario { "incomplete" => &["coverageOnly"], "rotation-boundary" => &["rotationSplit"], - "topology-mismatch" => &["topologyMismatch", "topologyMismatch"], _ => &[], }; if source_local_classes != expected_classes { @@ -3153,18 +3173,18 @@ fn hierarchy_artifact_request_mutations_fail_closed() { 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 invalid-offset request is the bounded control" + "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 bounded both-direction control" + "the shared request loader accepts the exact target-scoped control" ); let mutations = [ ( "wrong source ID", "sourceId", - serde_json::json!("server-hierarchy-control"), + serde_json::json!("server-hierarchy-transfer"), ), ("wrong direction", "direction", serde_json::json!("origin")), ( @@ -3178,21 +3198,21 @@ fn hierarchy_artifact_request_mutations_fail_closed() { serde_json::json!(["replmgr.log"]), ), ( - "missing origin companion", + "extra origin basename", "basenames", - serde_json::json!(["despool.log"]), + serde_json::json!(["rcmctrl.log", "replmgr.log"]), ), ( - "missing target companion", - "basenames", - serde_json::json!(["sender.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"][1][field] = value; + 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() { From a110fed41c76cda849deb193a096861e88e33f39 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 05:01:24 -0400 Subject: [PATCH 404/422] fix(sccm): require usable hierarchy targets --- .../src/sccm/server/windows/hierarchy.rs | 12 +- .../absent-remote-source/expected.json | 2 +- .../tests/sccm_hierarchy_reducer.rs | 112 ++++++++++++++++-- ...rarchy_and_replication_fixture_contract.rs | 30 ++--- 4 files changed, 133 insertions(+), 23 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs index fc5ad7d32..e20c1e0b4 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/hierarchy.rs @@ -635,6 +635,15 @@ pub fn analyze_hierarchy_replication( 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()); @@ -1262,7 +1271,8 @@ fn missing_target_source_requests( .into_iter() .filter(|(source_id, basename)| { !artifacts.iter().any(|artifact| { - artifact.source_id == *source_id + 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() 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 index 441f8964a..cb1641c1f 100644 --- 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 @@ -17,7 +17,7 @@ "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":"coverageAbsent"} + {"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/sccm_hierarchy_reducer.rs b/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs index 242f1c355..3371e31dd 100644 --- a/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs +++ b/crates/cmtraceopen-parser/tests/sccm_hierarchy_reducer.rs @@ -31,7 +31,7 @@ const SCENARIOS: &[&str] = &[ const FULL_OUTPUT_SHA256: &[(&str, &str)] = &[ ( "absent-remote-source", - "2ecf2bbce2368a23489ca4f81b772271ceb7ac743343daf668bf2c618c80551a", + "d433de24f68f9675b97afb3a65b6fe664f389dd3dafb780d5eaae1284f423153", ), ( "backlog-retry", @@ -547,6 +547,64 @@ fn remove_target_sources( 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, @@ -629,12 +687,7 @@ fn omitted_both_target_sources_emit_both_requests() { #[test] fn missing_target_gate_precedes_cross_artifact_contradiction_and_terminal_success() { - 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 (manifest, payloads) = tied_healthy_fixture(); let both_present = analyze_hierarchy_replication(&assess(&manifest, &payloads)).expect("sealed control"); @@ -666,6 +719,51 @@ fn missing_target_gate_precedes_cross_artifact_contradiction_and_terminal_succes } } +#[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 [ 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 index 237c8f670..feb0f9cba 100644 --- 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 @@ -1097,7 +1097,7 @@ fn invalid_offset_request_basis(scenario: &str, manifest: &Value) -> Option bool { ("server-hierarchy-transfer", "despool.log"), ] .into_iter() - .any(|(source_id, basename)| { - !target_source_declared(manifest, target_site, source_id, basename) - }) + .any(|(source_id, basename)| !target_source_usable(manifest, target_site, source_id, basename)) } fn missing_target_source_requests( @@ -1144,7 +1143,7 @@ fn missing_target_source_requests( ("server-hierarchy-control", "rcmctrl.log"), ("server-hierarchy-transfer", "despool.log"), ] { - if target_source_declared(manifest, target_site, source_id, basename) { + if target_source_usable(manifest, target_site, source_id, basename) { continue; } requests.insert(ArtifactRequestContract { @@ -1178,6 +1177,11 @@ fn derived_artifact_requests( } } 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 { @@ -2814,7 +2818,10 @@ fn hierarchy_gaps_requests_and_source_local_controls_are_bounded() { .filter_map(|request| request["reasonCode"].as_str()) .collect::>(); let expected_reasons: &[&str] = match *scenario { - "absent-remote-source" => &["missingTargetReceiveProcessApply", "coverageAbsent"], + "absent-remote-source" => &[ + "missingTargetReceiveProcessApply", + "missingTargetReceiveProcessApply", + ], "backlog-retry" => &[ "missingTargetReceiveProcessApply", "missingTargetReceiveProcessApply", @@ -3611,23 +3618,18 @@ fn hierarchy_coderabbit_5ead896_target_topology_requires_present_handles() { } #[test] -fn hierarchy_coderabbit_878a051_control_requests_include_target_rcmctrl() { +fn hierarchy_coderabbit_878a051_absent_target_rcmctrl_remains_missing() { let mut manifest = read_json("absent-remote-source", "manifest.json").expect("absent manifest loads"); - let mut expected = + 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"); - expected["artifactRequests"][0]["reasonCode"] = serde_json::json!("coverageAbsent"); - expected["artifactRequests"][1]["sourceId"] = serde_json::json!("server-hierarchy-transfer"); - expected["artifactRequests"][1]["basenames"] = serde_json::json!(["despool.log"]); - expected["artifactRequests"][1]["reasonCode"] = - serde_json::json!("missingTargetReceiveProcessApply"); let failures = artifact_request_failures("absent-remote-source", &manifest, &expected); assert!( failures.is_empty(), - "target-side rcmctrl coverage request is exact: {failures:?}" + "target-side absent rcmctrl remains an exact missing request: {failures:?}" ); } From de8967937bd377b0ab0aa91c40f5edffac8f6563 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 02:42:29 -0400 Subject: [PATCH 405/422] fix(sccm): close provider admin service review gaps --- crates/cmtraceopen-parser/src/sccm/catalog.rs | 2 +- .../src/sccm/server/windows/catalog.rs | 29 + .../src/sccm/server/windows/intake.rs | 48 +- .../src/sccm/server/windows/mod.rs | 2 + .../windows/provider_and_admin_service.rs | 918 +++++ .../admin-service-access-denied/expected.json | 29 +- .../admin-service-access-denied/manifest.json | 48 +- .../current/AdminService.log | 0 .../admin-service-auth-failure/expected.json | 40 +- .../admin-service-auth-failure/manifest.json | 55 +- .../current/AdminService.log | 0 .../expected.json | 42 +- .../manifest.json | 55 +- .../current/AdminService.log | 0 .../admin-service-parse-failed/expected.json | 30 +- .../admin-service-parse-failed/manifest.json | 52 +- .../admin-service-skipped/expected.json | 29 +- .../admin-service-skipped/manifest.json | 48 +- .../current/AdminService.log | 0 .../admin-service-success/expected.json | 43 +- .../admin-service-success/manifest.json | 55 +- .../current/AdminService.log | 3 +- .../blocked-deferred/expected.json | 79 +- .../blocked-deferred/manifest.json | 52 +- .../subject-provider}/current/Smsprov.log | 0 .../contradictory-evidence/expected.json | 127 +- .../contradictory-evidence/manifest.json | 50 +- .../current/u_ex_synthetic.log | 0 .../current/AdminService.log | 0 .../iis-supplemental/expected.json | 52 +- .../iis-supplemental/manifest.json | 87 +- .../current/AdminService.log | 1 - .../incomplete/expected.json | 42 +- .../incomplete/manifest.json | 55 +- .../current/AdminService.log | 0 .../subject-provider}/current/Smsprov.log | 0 .../privacy-redaction/expected.json | 76 +- .../privacy-redaction/manifest.json | 91 +- .../subject-provider}/current/Smsprov.log | 0 .../provider-authz-denied/expected.json | 40 +- .../provider-authz-denied/manifest.json | 49 +- .../subject-provider}/current/Smsprov.log | 0 .../provider-query-failure/expected.json | 41 +- .../provider-query-failure/manifest.json | 55 +- .../subject-provider}/current/Smsprov.log | 0 .../provider-retry/expected.json | 119 +- .../provider-retry/manifest.json | 50 +- .../provider-source-absent/expected.json | 29 +- .../provider-source-absent/manifest.json | 47 +- .../subject-provider}/current/Smsprov.log | 0 .../provider-source-capped/expected.json | 30 +- .../provider-source-capped/manifest.json | 54 +- .../provider-source-unsupported/expected.json | 29 +- .../provider-source-unsupported/manifest.json | 48 +- .../subject-provider}/current/Smsprov.log | 0 .../provider-success/expected.json | 45 +- .../provider-success/manifest.json | 49 +- .../subject-provider}/current/Smsprov.log | 0 .../provider-timeout/expected.json | 43 +- .../provider-timeout/manifest.json | 55 +- .../subject-provider}/current/Smsprov.log | 0 .../subject-provider}/lo_/Smsprov.lo_ | 0 .../rotation-boundary/expected.json | 36 +- .../rotation-boundary/manifest.json | 87 +- .../sccm_server_provider_and_admin_service.rs | 519 +++ ...ider_and_admin_service_fixture_contract.rs | 3123 +---------------- .../tests/sccm_spine_contract.rs | 4 +- 67 files changed, 2811 insertions(+), 3981 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/server/windows/provider_and_admin_service.rs rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/evidence/{server-admin-service/admin-service-lab => sccm/server/admin-service/server-admin-service/subject-admin-service}/current/AdminService.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/evidence/{server-admin-service/admin-service-lab => sccm/server/admin-service/server-admin-service/subject-admin-service}/current/AdminService.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/evidence/{server-admin-service/admin-service-lab => sccm/server/admin-service/server-admin-service/subject-admin-service}/current/AdminService.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/evidence/{server-admin-service/admin-service-lab => sccm/server/admin-service/server-admin-service/subject-admin-service}/current/AdminService.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/evidence/{server-admin-service/admin-service-lab => sccm/server/admin-service/server-admin-service/subject-admin-service}/current/AdminService.log (58%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/evidence/{server-provider/provider-local => sccm/server/provider/server-provider/subject-provider}/current/Smsprov.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/{server-admin-service-iis/admin-service-lab => sccm/server/admin-service/server-admin-service-iis/subject-admin-service}/current/u_ex_synthetic.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/{server-admin-service/admin-service-lab => sccm/server/admin-service/server-admin-service/subject-admin-service}/current/AdminService.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/evidence/{server-admin-service/admin-service-lab => sccm/server/admin-service/server-admin-service/subject-admin-service}/current/AdminService.log (50%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/{server-admin-service/admin-service-lab => sccm/server/admin-service/server-admin-service/subject-admin-service}/current/AdminService.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/{server-provider/provider-local => sccm/server/provider/server-provider/subject-provider}/current/Smsprov.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/evidence/{server-provider/provider-local => sccm/server/provider/server-provider/subject-provider}/current/Smsprov.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/evidence/{server-provider/provider-local => sccm/server/provider/server-provider/subject-provider}/current/Smsprov.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/evidence/{server-provider/provider-local => sccm/server/provider/server-provider/subject-provider}/current/Smsprov.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/evidence/{server-provider/provider-local => sccm/server/provider/server-provider/subject-provider}/current/Smsprov.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/evidence/{server-provider/provider-local => sccm/server/provider/server-provider/subject-provider}/current/Smsprov.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/evidence/{server-provider/provider-local => sccm/server/provider/server-provider/subject-provider}/current/Smsprov.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/evidence/{server-provider/provider-local => sccm/server/provider/server-provider/subject-provider}/current/Smsprov.log (100%) rename crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/evidence/{server-provider/provider-local => sccm/server/provider/server-provider/subject-provider}/lo_/Smsprov.lo_ (100%) create mode 100644 crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service.rs diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs index 92317b4f0..b8f020930 100644 --- a/crates/cmtraceopen-parser/src/sccm/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -695,7 +695,7 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ CatalogSpec { basename: "AdminService", logical_name: "adminService", - role: SccmRole::Provider, + role: SccmRole::AdminService, family: SccmArtifactFamily::AdminService, }, ]; diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs index eb6ceb141..0edb0e109 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/catalog.rs @@ -148,6 +148,33 @@ const SERVER_SOURCE_SPECS: &[SccmServerSourceSpec] = &[ 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] { @@ -204,6 +231,8 @@ pub(crate) fn expected_family(source_id: &str) -> Option { } "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, }) } diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 332317715..684a35718 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -1242,7 +1242,16 @@ fn normalize_artifact( false, ) } else { - (family, None, None, false) + let declared_rotation = parse_declared_rotation(&artifact.rotation)?; + if is_physical_state(&artifact.capture_state) && declared_rotation.is_none() { + return Err(SccmServerIntakeError::InvalidArtifact); + } + ( + family, + Some(artifact.original_basename.clone()), + declared_rotation, + false, + ) } } else if retained_unknown { ( @@ -2559,6 +2568,7 @@ fn validate_artifact_annotations( 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), } @@ -2665,6 +2675,29 @@ fn safe_manifest_artifact_id(value: &str, synthetic_fixture: bool) -> bool { | "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" + | "rotation-01-current" + | "rotation-02-lo" | "unknown-db-export" | "unrelated-02-wcm" | "unrelated-03-wsync" @@ -2693,6 +2726,9 @@ fn safe_source_id(value: &str, allow_unknown: bool, synthetic_fixture: bool) -> | "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 @@ -2773,6 +2809,9 @@ fn safe_lineage_id(value: &str, synthetic_fixture: bool) -> bool { | "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); } @@ -2833,6 +2872,9 @@ fn safe_path_fingerprint(value: &str, synthetic_fixture: bool) -> bool { | "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); @@ -2965,6 +3007,8 @@ fn safe_optional_handle(value: Option<&str>, synthetic_fixture: bool, domain: &s | "synthetic:host:site-02" | "synthetic:host:site-03" | "synthetic:host:wsus-01" + | "synthetic:host:provider-01" + | "synthetic:host:admin-service-01" ), "subject" => { matches!( @@ -2973,6 +3017,8 @@ fn safe_optional_handle(value: Option<&str>, synthetic_fixture: bool, domain: &s | "synthetic:subject:dp-02" | "synthetic:subject:sup-01" | "safe:sup:lab-sup-01" + | "synthetic:subject:provider-01" + | "synthetic:subject:admin-service-01" ) } _ => false, diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs index 82905d920..6a05f4ec8 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/mod.rs @@ -3,6 +3,7 @@ mod distribution_point; mod hierarchy; mod intake; mod management_point; +mod provider_and_admin_service; mod site_core; mod software_update_point; @@ -11,5 +12,6 @@ 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..e0ba59a0a --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/provider_and_admin_service.rs @@ -0,0 +1,918 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::sccm::{ + SccmArtifactFamily, SccmArtifactRequest, SccmConfidence, SccmCoverageState, SccmEvidence, + SccmEvidenceRef, SccmFindingClass, SccmKeyConfidence, SccmRole, 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"; +const PROVIDER_PROFILE: &str = "provider-server-5.00.test-v1"; +const ADMIN_PROFILE: &str = "admin-service-server-5.00.test-v1"; + +#[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 profile_id(self) -> &'static str { + match self { + Self::Provider => PROVIDER_PROFILE, + Self::AdminService => ADMIN_PROFILE, + } + } + + 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, + Failed, + BlockedOrDeferred, + Incomplete, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ProviderAdminServiceClassification { + Success, + ConfirmedFailure, + BlockedOrDeferred, + InsufficientEvidence, +} + +impl ProviderAdminServiceClassification { + pub fn shared_finding_class(self) -> Option { + match self { + Self::Success => None, + Self::ConfirmedFailure => Some(SccmFindingClass::ConfirmedFailure), + 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 profile_id: &'static str, + pub source_version: &'static str, + 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 endpoint_handle: 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_id: &'static str, +} + +#[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_request: Option, + 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 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, + profile_id: String, +} + +#[derive(Debug, Clone)] +struct Fact { + key: FactKey, + phase: ProviderAdminServicePhase, + disposition: ProviderAdminServiceDisposition, + terminal: bool, + evidence: SccmEvidenceRef, + utc_millis: 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(), + endpoint_handle: artifact.workflow_subject_handle.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 transactions = facts + .into_iter() + .filter_map(|(key, group)| { + reduce_transaction(key.clone(), group, poisoned.contains(&key), &scoped) + }) + .collect::>(); + transactions.sort_by(|left, right| left.transaction_id.cmp(&right.transaction_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() + .filter_map(|transaction| transaction.next_artifact_request.clone()) + { + if !artifact_requests.iter().any(|existing| { + existing.logical_id == request.logical_id && existing.role == request.role + }) { + artifact_requests.push(request); + } + } + artifact_requests.sort_by(|left, right| { + (left.logical_id.as_str(), role_name(&left.role)) + .cmp(&(right.logical_id.as_str(), role_name(&right.role))) + }); + artifact_requests.truncate(1); + + ProviderAdminServiceAnalysis { + workflow: "providerAndAdminService", + support_state: ProviderAdminServiceSupportState::SyntheticProfileOnly, + profiles: selected_profiles(&scoped), + coverage, + transactions, + 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(), + 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()) + }) + .map(|layer| ProviderAdminServiceProfile { + layer, + selection_state: if artifacts.iter().any(|artifact| { + artifact.source_id == layer.source_id() + && artifact.source_version.as_deref() == Some(SYNTHETIC_VERSION) + }) { + ProviderAdminServiceProfileSelection::SelectedSynthetic + } else { + ProviderAdminServiceProfileSelection::UnknownVersion + }, + profile_id: layer.profile_id(), + source_version: SYNTHETIC_VERSION, + limitation: + "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + }) + .collect() +} + +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)?; + if fields.get("Layer")?.as_str() != layer_name(layer) + || fields.get("ProfileId")?.as_str() != layer.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 key = FactKey { + layer, + request_id, + operation, + endpoint_handle: artifact.workflow_subject_handle.clone()?, + host_handle: artifact.producer_host_handle.clone()?, + profile_id: layer.profile_id().to_owned(), + }; + 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, + }; + 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 gaps = artifacts + .iter() + .filter(|artifact| { + artifact.source_id == key.layer.source_id() + && artifact.workflow_subject_handle.as_deref() == Some(&key.endpoint_handle) + && (artifact.state != SccmCoverageState::Captured + || artifact.fragment_complete == Some(false)) + }) + .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 conclusive = ordering_usable && phase_valid && gaps.is_empty() && !contradictory; + let (state, classification, confidence, summary) = + if !gaps.is_empty() || contradictory || !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 { + ( + 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 request = matches!( + state, + ProviderAdminServiceState::Incomplete | ProviderAdminServiceState::BlockedOrDeferred + ) + .then(|| artifact_request(key.layer)); + 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.endpoint_handle + ); + Some(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::Exact, + extraction_profile_id: match key.profile_id.as_str() { + PROVIDER_PROFILE => PROVIDER_PROFILE, + _ => ADMIN_PROFILE, + }, + }, + topology_compatibility: ProviderAdminServiceTopologyCompatibility::Exact, + timestamp_ordering: if ordering_usable { + ProviderAdminServiceTimestampOrdering::Usable + } else { + ProviderAdminServiceTimestampOrdering::Unusable + }, + correlation_eligible: conclusive + && matches!( + state, + ProviderAdminServiceState::Succeeded | ProviderAdminServiceState::Failed + ), + state, + classification, + confidence, + confidence_ceiling: confidence, + terminal_evidence: terminal_success || terminal_failure, + last_successful_phase, + coverage_gap_artifact_ids: gaps, + next_artifact_request: request, + public_summary: summary, + observations, + }) +} + +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 layers = artifacts + .iter() + .filter_map(|artifact| { + let layer = transaction_layer(artifact)?; + (artifact.state != SccmCoverageState::Captured + || artifact.fragment_complete == Some(false)) + .then_some(layer) + }) + .collect::>(); + layers + .pop_first() + .map(artifact_request) + .into_iter() + .collect() +} + +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 role_name(role: &SccmRole) -> &'static str { + match role { + SccmRole::Provider => "provider", + SccmRole::AdminService => "adminService", + _ => "other", + } +} 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 index 5d6b4bac4..313191842 100644 --- 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 @@ -1,28 +1,19 @@ { - "contractState": "preparationOnlyReviewedDependenciesAvailable", + "fixtureContractVersion": 1, "workflow": "providerAndAdminService", "scenario": "admin-service-access-denied", - "profiles": [ - { - "layer": "adminService", - "selectionState": "unknownVersion" - } - ], - "coverage": [ + "expectedCoverage": [ { "artifactId": "coverage-admin-access-denied", - "state": "accessDenied", "sourceId": "server-admin-service", - "layer": "adminService" - } - ], - "transactions": [], - "sourceLocalObservations": [], - "artifactRequests": [ - { - "logicalArtifactId": "server-admin-service", - "reason": "Capture bounded Admin Service evidence after access is authorized; access-denied coverage is not an outcome." + "producerRole": "adminService", + "state": "accessDenied" } ], - "crossSideCausalClaims": [] + "expectedTransactions": [], + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": { + "logicalId": "adminService", + "role": "adminService" + } } 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 index ba6878a48..edec0a0f9 100644 --- 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 @@ -1,44 +1,44 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, - "scenario": "admin-service-access-denied", - "bundle": { - "bundleRole": "server", - "workflow": "providerAndAdminService", - "capturedUtc": "2026-07-31T11:10:00Z" + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { + "captureHost": "LAB-CM01", "siteCode": "LAB", - "rolesObserved": ["provider"], - "endpoints": [ - { - "endpointId": "admin-service-lab", - "layer": "adminService", - "hostHandle": "safe:server:lab-provider-01", - "producerRole": "provider" - } + "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", - "producerRole": "provider", - "layer": "adminService", - "producerHostHandle": "safe:server:lab-provider-01", - "endpointId": "admin-service-lab", - "diagnosticUse": "primary", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_ADMIN_SERVICE_ROOT", "originalBasename": "AdminService.log", - "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/AdminService.log", - "pathFingerprint": "synthetic:admin-access-denied", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:admin-service-primary" + }, "rotation": { "kind": "current", - "lineageId": "admin-access-denied" + "lineageId": "admin-service-primary" }, "captureState": "accessDenied", - "sourceVersion": "5.00.UNKNOWN", - "collectedUtc": "2026-07-31T11:10:00Z" + "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/server-admin-service/admin-service-lab/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-auth-failure/evidence/server-admin-service/admin-service-lab/current/AdminService.log rename to 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 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 index 73a2c46cc..d6ef3c086 100644 --- 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 @@ -1,24 +1,26 @@ { - "contractState":"preparationOnlyReviewedDependenciesAvailable", - "workflow":"providerAndAdminService", - "scenario":"admin-service-auth-failure", - "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], - "coverage":[{"artifactId":"admin-auth-current","state":"captured","sourceId":"server-admin-service","layer":"adminService"}], - "transactions":[ + "fixtureContractVersion": 1, + "workflow": "providerAndAdminService", + "scenario": "admin-service-auth-failure", + "expectedCoverage": [ { - "transactionId":"adminService:66666666-6666-6666-6666-666666666666:safe-operation-admin-auth:admin-service-lab", - "layer":"adminService", - "key":{"requestId":"66666666-6666-6666-6666-666666666666","operationHandle":"safe-operation-admin-auth","endpointId":"admin-service-lab","confidence":"exact","extractionProfileId":"admin-service-server-5.00.test-v1"}, - "topologyCompatibility":"exact","timestampOrdering":"usable","state":"failed","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], - "publicSummary":"Admin Service authentication was explicitly rejected.", - "observations":[ - {"observationId":"admin-auth-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-auth-current","startLine":1,"endLine":1}]}, - {"observationId":"admin-auth-02-rejected","phase":"authenticateOrAuthorize","disposition":"failed","terminal":false,"evidence":[{"artifactId":"admin-auth-current","startLine":2,"endLine":2}]}, - {"observationId":"admin-auth-03-outcome","phase":"recordOutcome","disposition":"failed","terminal":true,"evidence":[{"artifactId":"admin-auth-current","startLine":3,"endLine":3}]} - ] + "artifactId": "admin-auth-current", + "sourceId": "server-admin-service", + "producerRole": "adminService", + "state": "captured" } ], - "sourceLocalObservations":[], - "artifactRequests":[], - "crossSideCausalClaims":[] + "expectedTransactions": [ + { + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "timestampOrdering": "usable", + "terminalEvidence": true, + "layer": "adminService", + "lastSuccessfulPhase": "receive" + } + ], + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": null } 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 index c1769bbbd..65b15153a 100644 --- 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 @@ -1,11 +1,50 @@ { - "sccmManifestVersion":1, - "proposalOnly":true, - "syntheticFixture":true, - "scenario":"admin-service-auth-failure", - "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T22:10:00Z"}, - "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"admin-service-lab","layer":"adminService","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, - "artifacts":[ - {"artifactId":"admin-auth-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:admin-auth-current","rotation":{"kind":"current","lineageId":"admin-auth","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:10:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":1248,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"} + "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/server-admin-service/admin-service-lab/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-backend-failure/evidence/server-admin-service/admin-service-lab/current/AdminService.log rename to 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 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 index 9b536f8ad..05fe0c50c 100644 --- 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 @@ -1,26 +1,26 @@ { - "contractState":"preparationOnlyReviewedDependenciesAvailable", - "workflow":"providerAndAdminService", - "scenario":"admin-service-backend-failure", - "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], - "coverage":[{"artifactId":"admin-backend-current","state":"captured","sourceId":"server-admin-service","layer":"adminService"}], - "transactions":[ + "fixtureContractVersion": 1, + "workflow": "providerAndAdminService", + "scenario": "admin-service-backend-failure", + "expectedCoverage": [ { - "transactionId":"adminService:77777777-7777-7777-7777-777777777777:safe-operation-admin-backend:admin-service-lab", - "layer":"adminService", - "key":{"requestId":"77777777-7777-7777-7777-777777777777","operationHandle":"safe-operation-admin-backend","endpointId":"admin-service-lab","confidence":"exact","extractionProfileId":"admin-service-server-5.00.test-v1"}, - "topologyCompatibility":"exact","timestampOrdering":"usable","state":"failed","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], - "publicSummary":"Admin Service recorded an explicit backend operation failure.", - "observations":[ - {"observationId":"admin-backend-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-backend-current","startLine":1,"endLine":1}]}, - {"observationId":"admin-backend-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-backend-current","startLine":2,"endLine":2}]}, - {"observationId":"admin-backend-03-route","phase":"route","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-backend-current","startLine":3,"endLine":3}]}, - {"observationId":"admin-backend-04-execute","phase":"executeBackendOperation","disposition":"failed","terminal":false,"evidence":[{"artifactId":"admin-backend-current","startLine":4,"endLine":4}]}, - {"observationId":"admin-backend-05-outcome","phase":"recordOutcome","disposition":"failed","terminal":true,"evidence":[{"artifactId":"admin-backend-current","startLine":5,"endLine":5}]} - ] + "artifactId": "admin-backend-current", + "sourceId": "server-admin-service", + "producerRole": "adminService", + "state": "captured" } ], - "sourceLocalObservations":[], - "artifactRequests":[], - "crossSideCausalClaims":[] + "expectedTransactions": [ + { + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "timestampOrdering": "usable", + "terminalEvidence": true, + "layer": "adminService", + "lastSuccessfulPhase": "route" + } + ], + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": null } 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 index 699ff8944..f8c50c95b 100644 --- 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 @@ -1,11 +1,50 @@ { - "sccmManifestVersion":1, - "proposalOnly":true, - "syntheticFixture":true, - "scenario":"admin-service-backend-failure", - "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T22:20:00Z"}, - "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"admin-service-lab","layer":"adminService","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, - "artifacts":[ - {"artifactId":"admin-backend-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:admin-backend-current","rotation":{"kind":"current","lineageId":"admin-backend","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:20:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":2099,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"} + "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/server-admin-service/admin-service-lab/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-parse-failed/evidence/server-admin-service/admin-service-lab/current/AdminService.log rename to 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 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 index a627e7cb8..fb3255615 100644 --- 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 @@ -1,29 +1,19 @@ { - "contractState": "preparationOnlyReviewedDependenciesAvailable", + "fixtureContractVersion": 1, "workflow": "providerAndAdminService", "scenario": "admin-service-parse-failed", - "profiles": [ - { - "layer": "adminService", - "selectionState": "selectedSynthetic", - "profileId": "admin-service-server-5.00.test-v1" - } - ], - "coverage": [ + "expectedCoverage": [ { "artifactId": "coverage-admin-parse-failed", - "state": "parseFailed", "sourceId": "server-admin-service", - "layer": "adminService" - } - ], - "transactions": [], - "sourceLocalObservations": [], - "artifactRequests": [ - { - "logicalArtifactId": "server-admin-service", - "reason": "Recapture or repair the bounded Admin Service source; malformed evidence is coverage, not an outcome." + "producerRole": "adminService", + "state": "parseFailed" } ], - "crossSideCausalClaims": [] + "expectedTransactions": [], + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": { + "logicalId": "adminService", + "role": "adminService" + } } 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 index fb5cf5772..09742cf92 100644 --- 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 @@ -1,52 +1,50 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, - "scenario": "admin-service-parse-failed", - "bundle": { - "bundleRole": "server", - "workflow": "providerAndAdminService", - "capturedUtc": "2026-07-31T11:20:00Z" + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { + "captureHost": "LAB-CM01", "siteCode": "LAB", - "rolesObserved": ["provider"], - "endpoints": [ - { - "endpointId": "admin-service-lab", - "layer": "adminService", - "hostHandle": "safe:server:lab-provider-01", - "producerRole": "provider" - } + "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", - "producerRole": "provider", - "layer": "adminService", - "producerHostHandle": "safe:server:lab-provider-01", - "endpointId": "admin-service-lab", - "diagnosticUse": "primary", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_ADMIN_SERVICE_ROOT", "originalBasename": "AdminService.log", - "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/AdminService.log", - "pathFingerprint": "synthetic:admin-parse-failed", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:admin-service-primary" + }, "rotation": { "kind": "current", - "lineageId": "admin-parse-failed", - "fragmentComplete": true + "lineageId": "admin-service-primary" }, "captureState": "parseFailed", - "sourceVersion": "5.00.TEST.0001", - "collectedUtc": "2026-07-31T11:20:00Z", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 64, + "sourceVersion": "5.00.TEST", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, - "bytesCopied": 64, - "relativePath": "evidence/server-admin-service/admin-service-lab/current/AdminService.log" + "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 index a104d6212..829ede742 100644 --- 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 @@ -1,28 +1,19 @@ { - "contractState": "preparationOnlyReviewedDependenciesAvailable", + "fixtureContractVersion": 1, "workflow": "providerAndAdminService", "scenario": "admin-service-skipped", - "profiles": [ - { - "layer": "adminService", - "selectionState": "unknownVersion" - } - ], - "coverage": [ + "expectedCoverage": [ { "artifactId": "coverage-admin-skipped", - "state": "skipped", "sourceId": "server-admin-service", - "layer": "adminService" - } - ], - "transactions": [], - "sourceLocalObservations": [], - "artifactRequests": [ - { - "logicalArtifactId": "server-admin-service", - "reason": "Capture the bounded Admin Service source; skipped coverage does not establish a workflow outcome." + "producerRole": "adminService", + "state": "skipped" } ], - "crossSideCausalClaims": [] + "expectedTransactions": [], + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": { + "logicalId": "adminService", + "role": "adminService" + } } 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 index 28ad35b1d..2b0a1cb5d 100644 --- 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 @@ -1,44 +1,44 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, - "scenario": "admin-service-skipped", - "bundle": { - "bundleRole": "server", - "workflow": "providerAndAdminService", - "capturedUtc": "2026-07-31T11:30:00Z" + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { + "captureHost": "LAB-CM01", "siteCode": "LAB", - "rolesObserved": ["provider"], - "endpoints": [ - { - "endpointId": "admin-service-lab", - "layer": "adminService", - "hostHandle": "safe:server:lab-provider-01", - "producerRole": "provider" - } + "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", - "producerRole": "provider", - "layer": "adminService", - "producerHostHandle": "safe:server:lab-provider-01", - "endpointId": "admin-service-lab", - "diagnosticUse": "primary", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_ADMIN_SERVICE_ROOT", "originalBasename": "AdminService.log", - "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/AdminService.log", - "pathFingerprint": "synthetic:admin-skipped", + "configuredPathProvenance": { + "state": "notRequested", + "pathFingerprint": "synthetic:path:admin-service-primary" + }, "rotation": { "kind": "current", - "lineageId": "admin-skipped" + "lineageId": "admin-service-primary" }, "captureState": "skipped", - "sourceVersion": "5.00.UNKNOWN", - "collectedUtc": "2026-07-31T11:30:00Z" + "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/server-admin-service/admin-service-lab/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/admin-service-success/evidence/server-admin-service/admin-service-lab/current/AdminService.log rename to 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 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 index 040c776d9..26fe175b8 100644 --- 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 @@ -1,27 +1,26 @@ { - "contractState":"preparationOnlyReviewedDependenciesAvailable", - "workflow":"providerAndAdminService", - "scenario":"admin-service-success", - "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], - "coverage":[{"artifactId":"admin-success-current","state":"captured","sourceId":"server-admin-service","layer":"adminService"}], - "transactions":[ + "fixtureContractVersion": 1, + "workflow": "providerAndAdminService", + "scenario": "admin-service-success", + "expectedCoverage": [ { - "transactionId":"adminService:55555555-5555-5555-5555-555555555555:safe-operation-admin-read:admin-service-lab", - "layer":"adminService", - "key":{"requestId":"55555555-5555-5555-5555-555555555555","operationHandle":"safe-operation-admin-read","endpointId":"admin-service-lab","confidence":"exact","extractionProfileId":"admin-service-server-5.00.test-v1"}, - "topologyCompatibility":"exact","timestampOrdering":"usable","state":"succeeded","classification":"success","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], - "publicSummary":"Admin Service request completed with explicit terminal evidence.", - "observations":[ - {"observationId":"admin-success-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-success-current","startLine":1,"endLine":1}]}, - {"observationId":"admin-success-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-success-current","startLine":2,"endLine":2}]}, - {"observationId":"admin-success-03-route","phase":"route","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-success-current","startLine":3,"endLine":3}]}, - {"observationId":"admin-success-04-backend","phase":"executeBackendOperation","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-success-current","startLine":4,"endLine":4}]}, - {"observationId":"admin-success-05-respond","phase":"respond","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-success-current","startLine":5,"endLine":5}]}, - {"observationId":"admin-success-06-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"admin-success-current","startLine":6,"endLine":6}]} - ] + "artifactId": "admin-success-current", + "sourceId": "server-admin-service", + "producerRole": "adminService", + "state": "captured" } ], - "sourceLocalObservations":[], - "artifactRequests":[], - "crossSideCausalClaims":[] + "expectedTransactions": [ + { + "state": "succeeded", + "classification": "success", + "confidence": "high", + "timestampOrdering": "usable", + "terminalEvidence": true, + "lastSuccessfulPhase": "recordOutcome", + "layer": "adminService" + } + ], + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": null } 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 index d85d541ad..e6ae18097 100644 --- 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 @@ -1,11 +1,50 @@ { - "sccmManifestVersion":1, - "proposalOnly":true, - "syntheticFixture":true, - "scenario":"admin-service-success", - "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T22:00:00Z"}, - "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"admin-service-lab","layer":"adminService","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, - "artifacts":[ - {"artifactId":"admin-success-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:admin-success-current","rotation":{"kind":"current","lineageId":"admin-success","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":2501,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"} + "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/server-admin-service/admin-service-lab/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 similarity index 58% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/evidence/server-admin-service/admin-service-lab/current/AdminService.log rename to 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 index 7147007ae..e8524100e 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/blocked-deferred/evidence/server-admin-service/admin-service-lab/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 @@ -1,2 +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 index 067f30e19..f5aa65055 100644 --- 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 @@ -1,78 +1,29 @@ { - "contractState": "preparationOnlyReviewedDependenciesAvailable", + "fixtureContractVersion": 1, "workflow": "providerAndAdminService", "scenario": "blocked-deferred", - "profiles": [ - { - "layer": "adminService", - "selectionState": "selectedSynthetic", - "profileId": "admin-service-server-5.00.test-v1" - } - ], - "coverage": [ + "expectedCoverage": [ { "artifactId": "blocked-deferred-admin-current", - "state": "captured", "sourceId": "server-admin-service", - "layer": "adminService" + "producerRole": "adminService", + "state": "captured" } ], - "transactions": [ + "expectedTransactions": [ { - "transactionId": "adminService:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee:safe-operation-admin-deferred:admin-service-lab", - "layer": "adminService", - "key": { - "requestId": "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", - "operationHandle": "safe-operation-admin-deferred", - "endpointId": "admin-service-lab", - "confidence": "exact", - "extractionProfileId": "admin-service-server-5.00.test-v1" - }, - "topologyCompatibility": "exact", + "state": "blockedOrDeferred", + "classification": "blockedOrDeferred", + "confidence": "moderate", "timestampOrdering": "usable", - "state": "incomplete", - "classification": "insufficientEvidence", - "confidence": "low", - "confidenceCeiling": "low", "terminalEvidence": false, - "coverageGapArtifactIds": [], - "publicSummary": "Admin Service evidence records a blocked or deferred request without a terminal outcome.", - "observations": [ - { - "observationId": "blocked-deferred-01-receive", - "phase": "receive", - "disposition": "succeeded", - "terminal": false, - "evidence": [ - { - "artifactId": "blocked-deferred-admin-current", - "startLine": 1, - "endLine": 1 - } - ] - }, - { - "observationId": "blocked-deferred-02-deferred", - "phase": "route", - "disposition": "pending", - "terminal": false, - "evidence": [ - { - "artifactId": "blocked-deferred-admin-current", - "startLine": 2, - "endLine": 2 - } - ] - } - ] - } - ], - "sourceLocalObservations": [], - "artifactRequests": [ - { - "logicalArtifactId": "server-admin-service", - "reason": "Capture the bounded Admin Service source lineage through an explicit terminal outcome after the blocked or deferred phase." + "lastSuccessfulPhase": "authenticateOrAuthorize", + "layer": "adminService" } ], - "crossSideCausalClaims": [] + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": { + "logicalId": "adminService", + "role": "adminService" + } } 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 index a312af821..cc61936af 100644 --- 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 @@ -1,52 +1,50 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, - "scenario": "blocked-deferred", - "bundle": { - "bundleRole": "server", - "workflow": "providerAndAdminService", - "capturedUtc": "2026-07-31T11:40:00Z" + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { + "captureHost": "LAB-CM01", "siteCode": "LAB", - "rolesObserved": ["provider"], - "endpoints": [ - { - "endpointId": "admin-service-lab", - "layer": "adminService", - "hostHandle": "safe:server:lab-provider-01", - "producerRole": "provider" - } + "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", - "producerRole": "provider", - "layer": "adminService", - "producerHostHandle": "safe:server:lab-provider-01", - "endpointId": "admin-service-lab", - "diagnosticUse": "primary", + "sourceKind": "ccmLog", + "originalPath": "REDACTED_ADMIN_SERVICE_ROOT", "originalBasename": "AdminService.log", - "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/AdminService.log", - "pathFingerprint": "synthetic:blocked-deferred-admin", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:admin-service-primary" + }, "rotation": { "kind": "current", - "lineageId": "admin-blocked-deferred", - "fragmentComplete": true + "lineageId": "admin-service-primary" }, "captureState": "captured", - "sourceVersion": "5.00.TEST.0001", - "collectedUtc": "2026-07-31T11:40:00Z", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 1257, + "sourceVersion": "5.00.TEST", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, - "bytesCopied": 826, - "relativePath": "evidence/server-admin-service/admin-service-lab/current/AdminService.log" + "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/server-provider/provider-local/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/evidence/server-provider/provider-local/current/Smsprov.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/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/contradictory-evidence/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/contradictory-evidence/expected.json index cc392bde4..8f5dd5006 100644 --- 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 @@ -1,130 +1,29 @@ { - "contractState": "preparationOnlyReviewedDependenciesAvailable", + "fixtureContractVersion": 1, "workflow": "providerAndAdminService", "scenario": "contradictory-evidence", - "profiles": [ - { - "layer": "provider", - "selectionState": "selectedSynthetic", - "profileId": "provider-server-5.00.test-v1" - } - ], - "coverage": [ + "expectedCoverage": [ { "artifactId": "contradictory-provider-current", - "state": "captured", "sourceId": "server-provider", - "layer": "provider" + "producerRole": "provider", + "state": "captured" } ], - "transactions": [ + "expectedTransactions": [ { - "transactionId": "provider:dddddddd-dddd-dddd-dddd-dddddddddddd:safe-operation-contradictory:provider-local", - "layer": "provider", - "key": { - "requestId": "dddddddd-dddd-dddd-dddd-dddddddddddd", - "operationHandle": "safe-operation-contradictory", - "endpointId": "provider-local", - "confidence": "exact", - "extractionProfileId": "provider-server-5.00.test-v1" - }, - "topologyCompatibility": "exact", - "timestampOrdering": "usable", "state": "incomplete", "classification": "insufficientEvidence", "confidence": "low", - "confidenceCeiling": "low", + "timestampOrdering": "usable", "terminalEvidence": true, - "coverageGapArtifactIds": [], - "publicSummary": "Provider evidence contains contradictory terminal outcomes for one exact request key.", - "observations": [ - { - "observationId": "contradictory-01-receive", - "phase": "receive", - "disposition": "succeeded", - "terminal": false, - "evidence": [ - { - "artifactId": "contradictory-provider-current", - "startLine": 1, - "endLine": 1 - } - ] - }, - { - "observationId": "contradictory-02-authorize", - "phase": "authenticateOrAuthorize", - "disposition": "succeeded", - "terminal": false, - "evidence": [ - { - "artifactId": "contradictory-provider-current", - "startLine": 2, - "endLine": 2 - } - ] - }, - { - "observationId": "contradictory-03-execute", - "phase": "executeProviderOperation", - "disposition": "succeeded", - "terminal": false, - "evidence": [ - { - "artifactId": "contradictory-provider-current", - "startLine": 3, - "endLine": 3 - } - ] - }, - { - "observationId": "contradictory-04-respond", - "phase": "respond", - "disposition": "succeeded", - "terminal": false, - "evidence": [ - { - "artifactId": "contradictory-provider-current", - "startLine": 4, - "endLine": 4 - } - ] - }, - { - "observationId": "contradictory-05-success", - "phase": "recordOutcome", - "disposition": "succeeded", - "terminal": true, - "evidence": [ - { - "artifactId": "contradictory-provider-current", - "startLine": 5, - "endLine": 5 - } - ] - }, - { - "observationId": "contradictory-06-failure", - "phase": "recordOutcome", - "disposition": "failed", - "terminal": true, - "evidence": [ - { - "artifactId": "contradictory-provider-current", - "startLine": 6, - "endLine": 6 - } - ] - } - ] - } - ], - "sourceLocalObservations": [], - "artifactRequests": [ - { - "logicalArtifactId": "server-provider", - "reason": "Capture the bounded Provider source lineage for the exact request key and reconcile contradictory terminal outcomes." + "lastSuccessfulPhase": "recordOutcome", + "layer": "provider" } ], - "crossSideCausalClaims": [] + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": { + "logicalId": "smsprov", + "role": "provider" + } } 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 index c81a7f624..26a1a16b7 100644 --- 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 @@ -1,54 +1,50 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, - "scenario": "contradictory-evidence", - "bundle": { - "bundleRole": "server", - "workflow": "providerAndAdminService", - "capturedUtc": "2026-07-30T23:10:00Z" + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { + "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": [ "provider" - ], - "endpoints": [ - { - "endpointId": "provider-local", - "layer": "provider", - "hostHandle": "safe:server:lab-provider-01", - "producerRole": "provider" - } ] }, "artifacts": [ { "artifactId": "contradictory-provider-current", - "sourceId": "server-provider", "producerRole": "provider", - "layer": "provider", - "producerHostHandle": "safe:server:lab-provider-01", - "endpointId": "provider-local", - "diagnosticUse": "primary", + "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", - "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/Smsprov.log", - "pathFingerprint": "synthetic:contradictory-provider-current", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:provider-primary" + }, "rotation": { "kind": "current", - "lineageId": "contradictory-provider", - "fragmentComplete": true + "lineageId": "provider-primary" }, "captureState": "captured", - "sourceVersion": "5.00.TEST.0001", - "collectedUtc": "2026-07-30T23:10:00Z", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 2416, + "sourceVersion": "5.00.TEST", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, - "bytesCopied": 2416, - "relativePath": "evidence/server-provider/provider-local/current/Smsprov.log" + "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/server-admin-service-iis/admin-service-lab/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/server-admin-service-iis/admin-service-lab/current/u_ex_synthetic.log rename to 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 diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/server-admin-service/admin-service-lab/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/iis-supplemental/evidence/server-admin-service/admin-service-lab/current/AdminService.log rename to 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 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 index d85822b78..a1622e2a7 100644 --- 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 @@ -1,30 +1,34 @@ { - "contractState":"preparationOnlyReviewedDependenciesAvailable", - "workflow":"providerAndAdminService", - "scenario":"iis-supplemental", - "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], - "coverage":[ - {"artifactId":"admin-iis-current","state":"captured","sourceId":"server-admin-service","layer":"adminService"}, - {"artifactId":"iis-supplemental-current","state":"captured","sourceId":"server-admin-service-iis","layer":"supplementalIis"} + "fixtureContractVersion": 1, + "workflow": "providerAndAdminService", + "scenario": "iis-supplemental", + "expectedCoverage": [ + { + "artifactId": "admin-iis-current", + "sourceId": "server-admin-service", + "producerRole": "adminService", + "state": "captured" + }, + { + "artifactId": "iis-supplemental-current", + "sourceId": "server-admin-service-iis", + "producerRole": "adminService", + "state": "captured" + } ], - "transactions":[ + "expectedTransactions": [ { - "transactionId":"adminService:88888888-8888-8888-8888-888888888888:safe-operation-admin-iis:admin-service-lab", - "layer":"adminService", - "key":{"requestId":"88888888-8888-8888-8888-888888888888","operationHandle":"safe-operation-admin-iis","endpointId":"admin-service-lab","confidence":"exact","extractionProfileId":"admin-service-server-5.00.test-v1"}, - "topologyCompatibility":"exact","timestampOrdering":"usable","state":"succeeded","classification":"success","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], - "publicSummary":"Admin Service evidence independently records a terminal success.", - "observations":[ - {"observationId":"admin-iis-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":1,"endLine":1}]}, - {"observationId":"admin-iis-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":2,"endLine":2}]}, - {"observationId":"admin-iis-03-route","phase":"route","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":3,"endLine":3}]}, - {"observationId":"admin-iis-04-backend","phase":"executeBackendOperation","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":4,"endLine":4}]}, - {"observationId":"admin-iis-05-respond","phase":"respond","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"admin-iis-current","startLine":5,"endLine":5}]}, - {"observationId":"admin-iis-06-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"admin-iis-current","startLine":6,"endLine":6}]} - ] + "state": "succeeded", + "classification": "success", + "confidence": "high", + "timestampOrdering": "usable", + "terminalEvidence": true, + "lastSuccessfulPhase": "recordOutcome", + "layer": "adminService" } ], - "sourceLocalObservations":[{"observationId":"iis-supplemental-01","kind":"supplementalOnly","layer":"supplementalIis","reason":"Scoped IIS evidence is optional context and cannot create an Admin Service transaction.","correlationEligible":false,"evidence":[{"artifactId":"iis-supplemental-current","startLine":2,"endLine":2}]}], - "artifactRequests":[], - "crossSideCausalClaims":[] + "expectedSourceLocalKinds": [ + "supplementalOnly" + ], + "expectedArtifactRequest": null } 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 index 690d5b80f..0f230ea73 100644 --- 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 @@ -1,12 +1,81 @@ { - "sccmManifestVersion":1, - "proposalOnly":true, - "syntheticFixture":true, - "scenario":"iis-supplemental", - "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T22:30:00Z"}, - "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"admin-service-lab","layer":"adminService","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, - "artifacts":[ - {"artifactId":"admin-iis-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:admin-iis-current","rotation":{"kind":"current","lineageId":"admin-iis","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:30:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":2495,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"}, - {"artifactId":"iis-supplemental-current","sourceId":"server-admin-service-iis","producerRole":"provider","layer":"supplementalIis","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"supplementalOnly","originalBasename":"u_ex_synthetic.log","sanitizedSourcePath":"SYNTHETIC://scoped-export/LAB/IIS/u_ex_synthetic.log","pathFingerprint":"synthetic:iis-supplemental-current","rotation":{"kind":"current","lineageId":"iis-supplemental","fragmentComplete":true},"captureState":"captured","sourceVersion":"IIS.TEST.0001","collectedUtc":"2026-07-30T22:30:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":157,"relativePath":"evidence/server-admin-service-iis/admin-service-lab/current/u_ex_synthetic.log"} + "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/server-admin-service/admin-service-lab/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 similarity index 50% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/evidence/server-admin-service/admin-service-lab/current/AdminService.log rename to 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 index 94f8eb8be..3a7a62e2d 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/incomplete/evidence/server-admin-service/admin-service-lab/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 @@ -1,2 +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 index 67da798e4..1adc9c94b 100644 --- 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 @@ -1,23 +1,29 @@ { - "contractState":"preparationOnlyReviewedDependenciesAvailable", - "workflow":"providerAndAdminService", - "scenario":"incomplete", - "profiles":[{"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}], - "coverage":[{"artifactId":"incomplete-admin-current","state":"captured","sourceId":"server-admin-service","layer":"adminService"}], - "transactions":[ + "fixtureContractVersion": 1, + "workflow": "providerAndAdminService", + "scenario": "incomplete", + "expectedCoverage": [ { - "transactionId":"adminService:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:safe-operation-admin-incomplete:admin-service-lab", - "layer":"adminService", - "key":{"requestId":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","operationHandle":"safe-operation-admin-incomplete","endpointId":"admin-service-lab","confidence":"exact","extractionProfileId":"admin-service-server-5.00.test-v1"}, - "topologyCompatibility":"exact","timestampOrdering":"usable","state":"incomplete","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low","terminalEvidence":false,"coverageGapArtifactIds":[], - "publicSummary":"Admin Service evidence stops before an explicit response or terminal outcome.", - "observations":[ - {"observationId":"incomplete-admin-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"incomplete-admin-current","startLine":1,"endLine":1}]}, - {"observationId":"incomplete-admin-02-route","phase":"route","disposition":"pending","terminal":false,"evidence":[{"artifactId":"incomplete-admin-current","startLine":2,"endLine":2}]} - ] + "artifactId": "incomplete-admin-current", + "sourceId": "server-admin-service", + "producerRole": "adminService", + "state": "captured" } ], - "sourceLocalObservations":[], - "artifactRequests":[{"logicalArtifactId":"server-admin-service","reason":"Capture the bounded Admin Service source lineage for the exact request key and terminal outcome."}], - "crossSideCausalClaims":[] + "expectedTransactions": [ + { + "state": "incomplete", + "classification": "insufficientEvidence", + "confidence": "low", + "timestampOrdering": "usable", + "terminalEvidence": false, + "lastSuccessfulPhase": "receive", + "layer": "adminService" + } + ], + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": { + "logicalId": "adminService", + "role": "adminService" + } } 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 index 3bbc3c972..340f43d28 100644 --- 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 @@ -1,11 +1,50 @@ { - "sccmManifestVersion":1, - "proposalOnly":true, - "syntheticFixture":true, - "scenario":"incomplete", - "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T23:00:00Z"}, - "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"admin-service-lab","layer":"adminService","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, - "artifacts":[ - {"artifactId":"incomplete-admin-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:incomplete-admin-current","rotation":{"kind":"current","lineageId":"admin-incomplete","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T23:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":830,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"} + "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/server-admin-service/admin-service-lab/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-admin-service/admin-service-lab/current/AdminService.log rename to 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 diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-provider/provider-local/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/evidence/server-provider/provider-local/current/Smsprov.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/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/privacy-redaction/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/privacy-redaction/expected.json index 7ac7c31d4..c793c0535 100644 --- 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 @@ -1,50 +1,44 @@ { - "contractState":"preparationOnlyReviewedDependenciesAvailable", - "workflow":"providerAndAdminService", - "scenario":"privacy-redaction", - "profiles":[ - {"layer":"adminService","selectionState":"selectedSynthetic","profileId":"admin-service-server-5.00.test-v1"}, - {"layer":"provider","selectionState":"selectedSynthetic","profileId":"provider-server-5.00.test-v1"} - ], - "coverage":[ - {"artifactId":"privacy-admin-current","state":"captured","sourceId":"server-admin-service","layer":"adminService"}, - {"artifactId":"privacy-provider-current","state":"captured","sourceId":"server-provider","layer":"provider"} + "fixtureContractVersion": 1, + "workflow": "providerAndAdminService", + "scenario": "privacy-redaction", + "expectedCoverage": [ + { + "artifactId": "privacy-admin-current", + "sourceId": "server-admin-service", + "producerRole": "adminService", + "state": "captured" + }, + { + "artifactId": "privacy-provider-current", + "sourceId": "server-provider", + "producerRole": "provider", + "state": "captured" + } ], - "transactions":[ + "expectedTransactions": [ { - "transactionId":"adminService:99999999-9999-9999-9999-999999999999:safe-operation-admin-privacy:admin-service-lab", - "layer":"adminService", - "key":{"requestId":"99999999-9999-9999-9999-999999999999","operationHandle":"safe-operation-admin-privacy","endpointId":"admin-service-lab","confidence":"exact","extractionProfileId":"admin-service-server-5.00.test-v1"}, - "topologyCompatibility":"exact","timestampOrdering":"usable","state":"succeeded","classification":"success","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], - "publicSummary":"Admin Service privacy fixture completed with redacted public evidence.", - "observations":[ - {"observationId":"privacy-admin-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-admin-current","startLine":1,"endLine":1}]}, - {"observationId":"privacy-admin-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-admin-current","startLine":2,"endLine":2}]}, - {"observationId":"privacy-admin-03-route","phase":"route","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-admin-current","startLine":3,"endLine":3}]}, - {"observationId":"privacy-admin-04-backend","phase":"executeBackendOperation","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-admin-current","startLine":4,"endLine":4}]}, - {"observationId":"privacy-admin-05-respond","phase":"respond","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-admin-current","startLine":5,"endLine":5}]}, - {"observationId":"privacy-admin-06-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"privacy-admin-current","startLine":6,"endLine":6}]} - ] + "state": "succeeded", + "classification": "success", + "confidence": "high", + "timestampOrdering": "usable", + "terminalEvidence": true, + "lastSuccessfulPhase": "recordOutcome", + "layer": "adminService" }, { - "transactionId":"provider:99999999-9999-9999-9999-999999999999:safe-operation-provider-privacy:provider-local", - "layer":"provider", - "key":{"requestId":"99999999-9999-9999-9999-999999999999","operationHandle":"safe-operation-provider-privacy","endpointId":"provider-local","confidence":"exact","extractionProfileId":"provider-server-5.00.test-v1"}, - "topologyCompatibility":"exact","timestampOrdering":"usable","state":"succeeded","classification":"success","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], - "publicSummary":"Provider privacy fixture completed with redacted public evidence.", - "observations":[ - {"observationId":"privacy-provider-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-provider-current","startLine":1,"endLine":1}]}, - {"observationId":"privacy-provider-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-provider-current","startLine":2,"endLine":2}]}, - {"observationId":"privacy-provider-03-execute","phase":"executeProviderOperation","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-provider-current","startLine":3,"endLine":3}]}, - {"observationId":"privacy-provider-04-respond","phase":"respond","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"privacy-provider-current","startLine":4,"endLine":4}]}, - {"observationId":"privacy-provider-05-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"privacy-provider-current","startLine":5,"endLine":5}]} - ] + "state": "succeeded", + "classification": "success", + "confidence": "high", + "timestampOrdering": "usable", + "terminalEvidence": true, + "lastSuccessfulPhase": "recordOutcome", + "layer": "provider" } ], - "sourceLocalObservations":[ - {"observationId":"privacy-redaction-admin","kind":"privacyRedacted","layer":"adminService","reason":"Caller and endpoint details remain outside public keys and summaries.","correlationEligible":false,"evidence":[{"artifactId":"privacy-admin-current","startLine":1,"endLine":1}]}, - {"observationId":"privacy-redaction-provider","kind":"privacyRedacted","layer":"provider","reason":"Caller, authorization, and query details remain outside public keys and summaries.","correlationEligible":false,"evidence":[{"artifactId":"privacy-provider-current","startLine":1,"endLine":1}]} + "expectedSourceLocalKinds": [ + "privacyRedacted", + "privacyRedacted" ], - "artifactRequests":[], - "crossSideCausalClaims":[] + "expectedArtifactRequest": null } 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 index 07f98a102..46a4da9e1 100644 --- 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 @@ -1,15 +1,82 @@ { - "sccmManifestVersion":1, - "proposalOnly":true, - "syntheticFixture":true, - "scenario":"privacy-redaction", - "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T22:40:00Z"}, - "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[ - {"endpointId":"admin-service-lab","layer":"adminService","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}, - {"endpointId":"provider-local","layer":"provider","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"} - ]}, - "artifacts":[ - {"artifactId":"privacy-admin-current","sourceId":"server-admin-service","producerRole":"provider","layer":"adminService","producerHostHandle":"safe:server:lab-provider-01","endpointId":"admin-service-lab","diagnosticUse":"primary","originalBasename":"AdminService.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/AdminService.log","pathFingerprint":"synthetic:privacy-admin-current","rotation":{"kind":"current","lineageId":"privacy-admin","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:40:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":2603,"relativePath":"evidence/server-admin-service/admin-service-lab/current/AdminService.log"}, - {"artifactId":"privacy-provider-current","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.log","pathFingerprint":"synthetic:privacy-provider-current","rotation":{"kind":"current","lineageId":"privacy-provider","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T22:40:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":2175,"relativePath":"evidence/server-provider/provider-local/current/Smsprov.log"} + "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/server-provider/provider-local/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-authz-denied/evidence/server-provider/provider-local/current/Smsprov.log rename to 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 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 index 7c0213083..4de165c3f 100644 --- 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 @@ -1,24 +1,26 @@ { - "contractState":"preparationOnlyReviewedDependenciesAvailable", - "workflow":"providerAndAdminService", - "scenario":"provider-authz-denied", - "profiles":[{"layer":"provider","selectionState":"selectedSynthetic","profileId":"provider-server-5.00.test-v1"}], - "coverage":[{"artifactId":"provider-authz-current","state":"captured","sourceId":"server-provider","layer":"provider"}], - "transactions":[ + "fixtureContractVersion": 1, + "workflow": "providerAndAdminService", + "scenario": "provider-authz-denied", + "expectedCoverage": [ { - "transactionId":"provider:22222222-2222-2222-2222-222222222222:safe-operation-update-device:provider-local", - "layer":"provider", - "key":{"requestId":"22222222-2222-2222-2222-222222222222","operationHandle":"safe-operation-update-device","endpointId":"provider-local","confidence":"exact","extractionProfileId":"provider-server-5.00.test-v1"}, - "topologyCompatibility":"exact","timestampOrdering":"usable","state":"failed","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], - "publicSummary":"Provider authorization was explicitly denied.", - "observations":[ - {"observationId":"provider-authz-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-authz-current","startLine":1,"endLine":1}]}, - {"observationId":"provider-authz-02-denied","phase":"authenticateOrAuthorize","disposition":"failed","terminal":false,"evidence":[{"artifactId":"provider-authz-current","startLine":2,"endLine":2}]}, - {"observationId":"provider-authz-03-outcome","phase":"recordOutcome","disposition":"failed","terminal":true,"evidence":[{"artifactId":"provider-authz-current","startLine":3,"endLine":3}]} - ] + "artifactId": "provider-authz-current", + "sourceId": "server-provider", + "producerRole": "provider", + "state": "captured" } ], - "sourceLocalObservations":[], - "artifactRequests":[], - "crossSideCausalClaims":[] + "expectedTransactions": [ + { + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "timestampOrdering": "usable", + "terminalEvidence": true, + "layer": "provider", + "lastSuccessfulPhase": "receive" + } + ], + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": null } 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 index 92ee4cac7..60ccf3d9e 100644 --- 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 @@ -1,11 +1,50 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, - "scenario": "provider-authz-denied", - "bundle": {"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T21:10:00Z"}, - "topology": {"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"provider-local","layer":"provider","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "provider" + ] + }, "artifacts": [ - {"artifactId":"provider-authz-current","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.log","pathFingerprint":"synthetic:provider-authz-current","rotation":{"kind":"current","lineageId":"provider-authz","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T21:10:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":1203,"relativePath":"evidence/server-provider/provider-local/current/Smsprov.log"} + { + "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/server-provider/provider-local/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-query-failure/evidence/server-provider/provider-local/current/Smsprov.log rename to 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 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 index b77adc316..e6eb09066 100644 --- 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 @@ -1,25 +1,26 @@ { - "contractState":"preparationOnlyReviewedDependenciesAvailable", - "workflow":"providerAndAdminService", - "scenario":"provider-query-failure", - "profiles":[{"layer":"provider","selectionState":"selectedSynthetic","profileId":"provider-server-5.00.test-v1"}], - "coverage":[{"artifactId":"provider-query-current","state":"captured","sourceId":"server-provider","layer":"provider"}], - "transactions":[ + "fixtureContractVersion": 1, + "workflow": "providerAndAdminService", + "scenario": "provider-query-failure", + "expectedCoverage": [ { - "transactionId":"provider:33333333-3333-3333-3333-333333333333:safe-operation-query-device:provider-local", - "layer":"provider", - "key":{"requestId":"33333333-3333-3333-3333-333333333333","operationHandle":"safe-operation-query-device","endpointId":"provider-local","confidence":"exact","extractionProfileId":"provider-server-5.00.test-v1"}, - "topologyCompatibility":"exact","timestampOrdering":"usable","state":"failed","classification":"confirmedFailure","confidence":"high","confidenceCeiling":"high","terminalEvidence":true,"coverageGapArtifactIds":[], - "publicSummary":"Provider operation recorded an explicit terminal failure.", - "observations":[ - {"observationId":"provider-query-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-query-current","startLine":1,"endLine":1}]}, - {"observationId":"provider-query-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-query-current","startLine":2,"endLine":2}]}, - {"observationId":"provider-query-03-execute","phase":"executeProviderOperation","disposition":"failed","terminal":false,"evidence":[{"artifactId":"provider-query-current","startLine":3,"endLine":3}]}, - {"observationId":"provider-query-04-outcome","phase":"recordOutcome","disposition":"failed","terminal":true,"evidence":[{"artifactId":"provider-query-current","startLine":4,"endLine":4}]} - ] + "artifactId": "provider-query-current", + "sourceId": "server-provider", + "producerRole": "provider", + "state": "captured" } ], - "sourceLocalObservations":[], - "artifactRequests":[], - "crossSideCausalClaims":[] + "expectedTransactions": [ + { + "state": "failed", + "classification": "confirmedFailure", + "confidence": "high", + "timestampOrdering": "usable", + "terminalEvidence": true, + "layer": "provider", + "lastSuccessfulPhase": "authenticateOrAuthorize" + } + ], + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": null } 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 index 47da9a4b7..a174d73bb 100644 --- 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 @@ -1,11 +1,50 @@ { - "sccmManifestVersion":1, - "proposalOnly":true, - "syntheticFixture":true, - "scenario":"provider-query-failure", - "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T21:20:00Z"}, - "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"provider-local","layer":"provider","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, - "artifacts":[ - {"artifactId":"provider-query-current","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.log","pathFingerprint":"synthetic:provider-query-current","rotation":{"kind":"current","lineageId":"provider-query","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T21:20:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":1612,"relativePath":"evidence/server-provider/provider-local/current/Smsprov.log"} + "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/server-provider/provider-local/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/evidence/server-provider/provider-local/current/Smsprov.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-retry/expected.json index c1b51744b..df84553a7 100644 --- 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 @@ -1,125 +1,26 @@ { - "contractState": "preparationOnlyReviewedDependenciesAvailable", + "fixtureContractVersion": 1, "workflow": "providerAndAdminService", "scenario": "provider-retry", - "profiles": [ - { - "layer": "provider", - "selectionState": "selectedSynthetic", - "profileId": "provider-server-5.00.test-v1" - } - ], - "coverage": [ + "expectedCoverage": [ { "artifactId": "provider-retry-current", - "state": "captured", "sourceId": "server-provider", - "layer": "provider" + "producerRole": "provider", + "state": "captured" } ], - "transactions": [ + "expectedTransactions": [ { - "transactionId": "provider:cccccccc-cccc-cccc-cccc-cccccccccccc:safe-operation-provider-retry:provider-local", - "layer": "provider", - "key": { - "requestId": "cccccccc-cccc-cccc-cccc-cccccccccccc", - "operationHandle": "safe-operation-provider-retry", - "endpointId": "provider-local", - "confidence": "exact", - "extractionProfileId": "provider-server-5.00.test-v1" - }, - "topologyCompatibility": "exact", - "timestampOrdering": "usable", "state": "succeeded", "classification": "success", "confidence": "high", - "confidenceCeiling": "high", + "timestampOrdering": "usable", "terminalEvidence": true, - "coverageGapArtifactIds": [], - "publicSummary": "Provider retry completed with explicit terminal recovery evidence.", - "observations": [ - { - "observationId": "provider-retry-01-receive", - "phase": "receive", - "disposition": "succeeded", - "terminal": false, - "evidence": [ - { - "artifactId": "provider-retry-current", - "startLine": 1, - "endLine": 1 - } - ] - }, - { - "observationId": "provider-retry-02-authorize", - "phase": "authenticateOrAuthorize", - "disposition": "succeeded", - "terminal": false, - "evidence": [ - { - "artifactId": "provider-retry-current", - "startLine": 2, - "endLine": 2 - } - ] - }, - { - "observationId": "provider-retry-03-retryable", - "phase": "executeProviderOperation", - "disposition": "retryableFailure", - "terminal": false, - "evidence": [ - { - "artifactId": "provider-retry-current", - "startLine": 3, - "endLine": 3 - } - ] - }, - { - "observationId": "provider-retry-04-recovered", - "phase": "executeProviderOperation", - "disposition": "succeeded", - "terminal": false, - "evidence": [ - { - "artifactId": "provider-retry-current", - "startLine": 4, - "endLine": 4 - } - ] - }, - { - "observationId": "provider-retry-05-respond", - "phase": "respond", - "disposition": "succeeded", - "terminal": false, - "evidence": [ - { - "artifactId": "provider-retry-current", - "startLine": 5, - "endLine": 5 - } - ] - }, - { - "observationId": "provider-retry-06-outcome", - "phase": "recordOutcome", - "disposition": "succeeded", - "terminal": true, - "evidence": [ - { - "artifactId": "provider-retry-current", - "startLine": 6, - "endLine": 6 - } - ] - } - ] + "lastSuccessfulPhase": "recordOutcome", + "layer": "provider" } ], - "sourceLocalObservations": [], - "artifactRequests": [], - "crossSideCausalClaims": [] + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": null } 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 index 5d9c59190..3b5c5d42c 100644 --- 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 @@ -1,54 +1,50 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, - "scenario": "provider-retry", - "bundle": { - "bundleRole": "server", - "workflow": "providerAndAdminService", - "capturedUtc": "2026-07-30T23:00:00Z" + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { + "captureHost": "LAB-CM01", "siteCode": "LAB", "rolesObserved": [ "provider" - ], - "endpoints": [ - { - "endpointId": "provider-local", - "layer": "provider", - "hostHandle": "safe:server:lab-provider-01", - "producerRole": "provider" - } ] }, "artifacts": [ { "artifactId": "provider-retry-current", - "sourceId": "server-provider", "producerRole": "provider", - "layer": "provider", - "producerHostHandle": "safe:server:lab-provider-01", - "endpointId": "provider-local", - "diagnosticUse": "primary", + "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", - "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/Smsprov.log", - "pathFingerprint": "synthetic:provider-retry-current", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:provider-primary" + }, "rotation": { "kind": "current", - "lineageId": "provider-retry", - "fragmentComplete": true + "lineageId": "provider-primary" }, "captureState": "captured", - "sourceVersion": "5.00.TEST.0001", - "collectedUtc": "2026-07-30T23:00:00Z", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 2444, + "sourceVersion": "5.00.TEST", "encoding": "utf-8", "collectionLimit": { "byteLimit": 4096, "limitApplied": false }, - "bytesCopied": 2444, - "relativePath": "evidence/server-provider/provider-local/current/Smsprov.log" + "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 index a540bc1a8..1282e21ee 100644 --- 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 @@ -1,28 +1,19 @@ { - "contractState": "preparationOnlyReviewedDependenciesAvailable", + "fixtureContractVersion": 1, "workflow": "providerAndAdminService", "scenario": "provider-source-absent", - "profiles": [ - { - "layer": "provider", - "selectionState": "unknownVersion" - } - ], - "coverage": [ + "expectedCoverage": [ { "artifactId": "coverage-provider-absent", - "state": "absent", "sourceId": "server-provider", - "layer": "provider" - } - ], - "transactions": [], - "sourceLocalObservations": [], - "artifactRequests": [ - { - "logicalArtifactId": "server-provider", - "reason": "Capture the bounded Provider source; absent coverage does not establish a workflow outcome." + "producerRole": "provider", + "state": "absent" } ], - "crossSideCausalClaims": [] + "expectedTransactions": [], + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": { + "logicalId": "smsprov", + "role": "provider" + } } 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 index e6a53d795..655d6a178 100644 --- 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 @@ -1,44 +1,43 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, - "scenario": "provider-source-absent", - "bundle": { - "bundleRole": "server", - "workflow": "providerAndAdminService", - "capturedUtc": "2026-07-31T11:50:00Z" + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { + "captureHost": "LAB-CM01", "siteCode": "LAB", - "rolesObserved": ["provider"], - "endpoints": [ - { - "endpointId": "provider-local", - "layer": "provider", - "hostHandle": "safe:server:lab-provider-01", - "producerRole": "provider" - } + "rolesObserved": [ + "provider" ] }, "artifacts": [ { "artifactId": "coverage-provider-absent", - "sourceId": "server-provider", "producerRole": "provider", - "layer": "provider", - "producerHostHandle": "safe:server:lab-provider-01", - "endpointId": "provider-local", - "diagnosticUse": "primary", + "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", - "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/Smsprov.log", - "pathFingerprint": "synthetic:provider-absent", + "configuredPathProvenance": { + "state": "defaultCandidate", + "pathFingerprint": "synthetic:path:provider-primary" + }, "rotation": { "kind": "current", - "lineageId": "provider-absent" + "lineageId": "provider-primary" }, "captureState": "absent", - "sourceVersion": "5.00.UNKNOWN", - "collectedUtc": "2026-07-31T11:50:00Z" + "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/server-provider/provider-local/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-source-capped/evidence/server-provider/provider-local/current/Smsprov.log rename to 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 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 index 24ba97f4a..93036ba5b 100644 --- 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 @@ -1,29 +1,21 @@ { - "contractState": "preparationOnlyReviewedDependenciesAvailable", + "fixtureContractVersion": 1, "workflow": "providerAndAdminService", "scenario": "provider-source-capped", - "profiles": [ - { - "layer": "provider", - "selectionState": "selectedSynthetic", - "profileId": "provider-server-5.00.test-v1" - } - ], - "coverage": [ + "expectedCoverage": [ { "artifactId": "coverage-provider-capped", - "state": "capped", "sourceId": "server-provider", - "layer": "provider" + "producerRole": "provider", + "state": "capped" } ], - "transactions": [], - "sourceLocalObservations": [], - "artifactRequests": [ - { - "logicalArtifactId": "server-provider", - "reason": "Recapture the bounded Provider source without truncating the required transaction evidence." - } + "expectedTransactions": [], + "expectedSourceLocalKinds": [ + "rotationFragment" ], - "crossSideCausalClaims": [] + "expectedArtifactRequest": { + "logicalId": "smsprov", + "role": "provider" + } } 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 index a96170c54..8880e310e 100644 --- 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 @@ -1,52 +1,52 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, - "scenario": "provider-source-capped", - "bundle": { - "bundleRole": "server", - "workflow": "providerAndAdminService", - "capturedUtc": "2026-07-31T12:00:00Z" + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { + "captureHost": "LAB-CM01", "siteCode": "LAB", - "rolesObserved": ["provider"], - "endpoints": [ - { - "endpointId": "provider-local", - "layer": "provider", - "hostHandle": "safe:server:lab-provider-01", - "producerRole": "provider" - } + "rolesObserved": [ + "provider" ] }, "artifacts": [ { "artifactId": "coverage-provider-capped", - "sourceId": "server-provider", "producerRole": "provider", - "layer": "provider", - "producerHostHandle": "safe:server:lab-provider-01", - "endpointId": "provider-local", - "diagnosticUse": "primary", + "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", - "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/Smsprov.log", - "pathFingerprint": "synthetic:provider-capped", + "configuredPathProvenance": { + "state": "configured", + "pathFingerprint": "synthetic:path:provider-primary" + }, "rotation": { "kind": "current", - "lineageId": "provider-capped", - "fragmentComplete": false + "lineageId": "provider-primary" }, "captureState": "capped", - "sourceVersion": "5.00.TEST.0001", - "collectedUtc": "2026-07-31T12:00:00Z", + "collectedUtc": "2026-08-01T00:00:00Z", + "bytesCopied": 398, + "sourceVersion": "5.00.TEST", "encoding": "utf-8", "collectionLimit": { "byteLimit": 398, "limitApplied": true }, - "bytesCopied": 398, - "relativePath": "evidence/server-provider/provider-local/current/Smsprov.log" + "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 index 62d6096ab..544e14c0e 100644 --- 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 @@ -1,28 +1,19 @@ { - "contractState": "preparationOnlyReviewedDependenciesAvailable", + "fixtureContractVersion": 1, "workflow": "providerAndAdminService", "scenario": "provider-source-unsupported", - "profiles": [ - { - "layer": "provider", - "selectionState": "unknownVersion" - } - ], - "coverage": [ + "expectedCoverage": [ { "artifactId": "coverage-provider-unsupported", - "state": "unsupported", "sourceId": "server-provider", - "layer": "provider" - } - ], - "transactions": [], - "sourceLocalObservations": [], - "artifactRequests": [ - { - "logicalArtifactId": "server-provider", - "reason": "Use a supported versioned Provider source profile before evaluating request outcomes." + "producerRole": "provider", + "state": "unsupported" } ], - "crossSideCausalClaims": [] + "expectedTransactions": [], + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": { + "logicalId": "smsprov", + "role": "provider" + } } 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 index e9d5f930e..3d44f30e6 100644 --- 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 @@ -1,44 +1,44 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, - "scenario": "provider-source-unsupported", - "bundle": { - "bundleRole": "server", - "workflow": "providerAndAdminService", - "capturedUtc": "2026-07-31T12:10:00Z" + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" }, + "bundleRole": "server", "topology": { + "captureHost": "LAB-CM01", "siteCode": "LAB", - "rolesObserved": ["provider"], - "endpoints": [ - { - "endpointId": "provider-local", - "layer": "provider", - "hostHandle": "safe:server:lab-provider-01", - "producerRole": "provider" - } + "rolesObserved": [ + "provider" ] }, "artifacts": [ { "artifactId": "coverage-provider-unsupported", - "sourceId": "server-provider", "producerRole": "provider", - "layer": "provider", - "producerHostHandle": "safe:server:lab-provider-01", - "endpointId": "provider-local", - "diagnosticUse": "primary", + "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", - "sanitizedSourcePath": "SYNTHETIC://configured-root/LAB/Logs/Smsprov.log", - "pathFingerprint": "synthetic:provider-unsupported", + "configuredPathProvenance": { + "state": "defaultCandidate", + "pathFingerprint": "synthetic:path:provider-primary" + }, "rotation": { "kind": "current", - "lineageId": "provider-unsupported" + "lineageId": "provider-primary" }, "captureState": "unsupported", - "sourceVersion": "5.00.UNKNOWN", - "collectedUtc": "2026-07-31T12:10:00Z" + "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/server-provider/provider-local/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/evidence/server-provider/provider-local/current/Smsprov.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/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-success/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-success/expected.json index 31f54e747..0f99cfc72 100644 --- 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 @@ -1,33 +1,26 @@ { - "contractState": "preparationOnlyReviewedDependenciesAvailable", + "fixtureContractVersion": 1, "workflow": "providerAndAdminService", "scenario": "provider-success", - "profiles": [{"layer":"provider","selectionState":"selectedSynthetic","profileId":"provider-server-5.00.test-v1"}], - "coverage": [{"artifactId":"provider-success-current","state":"captured","sourceId":"server-provider","layer":"provider"}], - "transactions": [ + "expectedCoverage": [ { - "transactionId":"provider:11111111-1111-1111-1111-111111111111:safe-operation-read-device:provider-local", - "layer":"provider", - "key":{"requestId":"11111111-1111-1111-1111-111111111111","operationHandle":"safe-operation-read-device","endpointId":"provider-local","confidence":"exact","extractionProfileId":"provider-server-5.00.test-v1"}, - "topologyCompatibility":"exact", - "timestampOrdering":"usable", - "state":"succeeded", - "classification":"success", - "confidence":"high", - "confidenceCeiling":"high", - "terminalEvidence":true, - "coverageGapArtifactIds":[], - "publicSummary":"Provider operation completed with explicit terminal evidence.", - "observations":[ - {"observationId":"provider-success-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-success-current","startLine":1,"endLine":1}]}, - {"observationId":"provider-success-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-success-current","startLine":2,"endLine":2}]}, - {"observationId":"provider-success-03-execute","phase":"executeProviderOperation","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-success-current","startLine":3,"endLine":3}]}, - {"observationId":"provider-success-04-respond","phase":"respond","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-success-current","startLine":4,"endLine":4}]}, - {"observationId":"provider-success-05-outcome","phase":"recordOutcome","disposition":"succeeded","terminal":true,"evidence":[{"artifactId":"provider-success-current","startLine":5,"endLine":5}]} - ] + "artifactId": "provider-success-current", + "sourceId": "server-provider", + "producerRole": "provider", + "state": "captured" } ], - "sourceLocalObservations": [], - "artifactRequests": [], - "crossSideCausalClaims": [] + "expectedTransactions": [ + { + "state": "succeeded", + "classification": "success", + "confidence": "high", + "timestampOrdering": "usable", + "terminalEvidence": true, + "lastSuccessfulPhase": "recordOutcome", + "layer": "provider" + } + ], + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": null } 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 index 71dea661f..8d7b02a70 100644 --- 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 @@ -1,11 +1,50 @@ { "sccmManifestVersion": 1, - "proposalOnly": true, "syntheticFixture": true, - "scenario": "provider-success", - "bundle": {"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T21:00:00Z"}, - "topology": {"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"provider-local","layer":"provider","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, + "proposalOnly": true, + "privacy": { + "synthetic": true, + "rawPaths": "redacted" + }, + "bundleRole": "server", + "topology": { + "captureHost": "LAB-CM01", + "siteCode": "LAB", + "rolesObserved": [ + "provider" + ] + }, "artifacts": [ - {"artifactId":"provider-success-current","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.log","pathFingerprint":"synthetic:provider-success-current","rotation":{"kind":"current","lineageId":"provider-success","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T21:00:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":2008,"relativePath":"evidence/server-provider/provider-local/current/Smsprov.log"} + { + "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/server-provider/provider-local/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/evidence/server-provider/provider-local/current/Smsprov.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/provider-timeout/expected.json index 8235b6577..209fd093b 100644 --- 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 @@ -1,24 +1,29 @@ { - "contractState":"preparationOnlyReviewedDependenciesAvailable", - "workflow":"providerAndAdminService", - "scenario":"provider-timeout", - "profiles":[{"layer":"provider","selectionState":"selectedSynthetic","profileId":"provider-server-5.00.test-v1"}], - "coverage":[{"artifactId":"provider-timeout-current","state":"captured","sourceId":"server-provider","layer":"provider"}], - "transactions":[ + "fixtureContractVersion": 1, + "workflow": "providerAndAdminService", + "scenario": "provider-timeout", + "expectedCoverage": [ { - "transactionId":"provider:44444444-4444-4444-4444-444444444444:safe-operation-provider-timeout:provider-local", - "layer":"provider", - "key":{"requestId":"44444444-4444-4444-4444-444444444444","operationHandle":"safe-operation-provider-timeout","endpointId":"provider-local","confidence":"exact","extractionProfileId":"provider-server-5.00.test-v1"}, - "topologyCompatibility":"exact","timestampOrdering":"unusableInvalidOffset","state":"incomplete","classification":"insufficientEvidence","confidence":"low","confidenceCeiling":"low","terminalEvidence":false,"coverageGapArtifactIds":[], - "publicSummary":"Provider evidence stops before an explicit terminal outcome.", - "observations":[ - {"observationId":"provider-timeout-01-receive","phase":"receive","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-timeout-current","startLine":1,"endLine":1}]}, - {"observationId":"provider-timeout-02-authorize","phase":"authenticateOrAuthorize","disposition":"succeeded","terminal":false,"evidence":[{"artifactId":"provider-timeout-current","startLine":2,"endLine":2}]}, - {"observationId":"provider-timeout-03-execute","phase":"executeProviderOperation","disposition":"pending","terminal":false,"evidence":[{"artifactId":"provider-timeout-current","startLine":3,"endLine":3}]} - ] + "artifactId": "provider-timeout-current", + "sourceId": "server-provider", + "producerRole": "provider", + "state": "captured" } ], - "sourceLocalObservations":[], - "artifactRequests":[{"logicalArtifactId":"server-provider","reason":"Capture the bounded Provider source lineage for the exact request key and terminal outcome."}], - "crossSideCausalClaims":[] + "expectedTransactions": [ + { + "state": "incomplete", + "classification": "insufficientEvidence", + "confidence": "low", + "timestampOrdering": "unusable", + "terminalEvidence": false, + "lastSuccessfulPhase": null, + "layer": "provider" + } + ], + "expectedSourceLocalKinds": [], + "expectedArtifactRequest": { + "logicalId": "smsprov", + "role": "provider" + } } 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 index acd52256b..4558fa087 100644 --- 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 @@ -1,11 +1,50 @@ { - "sccmManifestVersion":1, - "proposalOnly":true, - "syntheticFixture":true, - "scenario":"provider-timeout", - "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T21:30:00Z"}, - "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"provider-local","layer":"provider","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, - "artifacts":[ - {"artifactId":"provider-timeout-current","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.log","pathFingerprint":"synthetic:provider-timeout-current","rotation":{"kind":"current","lineageId":"provider-timeout","fragmentComplete":true},"captureState":"captured","sourceVersion":"5.00.TEST.0001","collectedUtc":"2026-07-30T21:30:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":1234,"relativePath":"evidence/server-provider/provider-local/current/Smsprov.log"} + "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/server-provider/provider-local/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 similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/evidence/server-provider/provider-local/current/Smsprov.log rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/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/server-provider/provider-local/lo_/Smsprov.lo_ b/crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/evidence/sccm/server/provider/server-provider/subject-provider/lo_/Smsprov.lo_ similarity index 100% rename from crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/evidence/server-provider/provider-local/lo_/Smsprov.lo_ rename to crates/cmtraceopen-parser/tests/fixtures/sccm/server/provider_and_admin_service/rotation-boundary/evidence/sccm/server/provider/server-provider/subject-provider/lo_/Smsprov.lo_ 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 index 4170c4ff0..0b82f1b1a 100644 --- 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 @@ -1,14 +1,28 @@ { - "contractState":"preparationOnlyReviewedDependenciesAvailable", - "workflow":"providerAndAdminService", - "scenario":"rotation-boundary", - "profiles":[{"layer":"provider","selectionState":"unknownVersion"}], - "coverage":[ - {"artifactId":"rotation-01-current","state":"captured","sourceId":"server-provider","layer":"provider"}, - {"artifactId":"rotation-02-lo","state":"captured","sourceId":"server-provider","layer":"provider"} + "fixtureContractVersion": 1, + "workflow": "providerAndAdminService", + "scenario": "rotation-boundary", + "expectedCoverage": [ + { + "artifactId": "rotation-01-current", + "sourceId": "server-provider", + "producerRole": "provider", + "state": "parseFailed" + }, + { + "artifactId": "rotation-02-lo", + "sourceId": "server-provider", + "producerRole": "provider", + "state": "parseFailed" + } ], - "transactions":[], - "sourceLocalObservations":[{"observationId":"rotation-fragment-01","kind":"rotationFragment","layer":"provider","reason":"Split rotation fragments and an unknown version cannot form an exact request key.","correlationEligible":false,"evidence":[{"artifactId":"rotation-01-current","startLine":1,"endLine":1},{"artifactId":"rotation-02-lo","startLine":1,"endLine":1}]}], - "artifactRequests":[{"logicalArtifactId":"server-provider","reason":"Capture one bounded complete Provider rotation with known version provenance."}], - "crossSideCausalClaims":[] + "expectedTransactions": [], + "expectedSourceLocalKinds": [ + "rotationFragment", + "rotationFragment" + ], + "expectedArtifactRequest": { + "logicalId": "smsprov", + "role": "provider" + } } 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 index e62f80740..2332dbc88 100644 --- 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 @@ -1,12 +1,81 @@ { - "sccmManifestVersion":1, - "proposalOnly":true, - "syntheticFixture":true, - "scenario":"rotation-boundary", - "bundle":{"bundleRole":"server","workflow":"providerAndAdminService","capturedUtc":"2026-07-30T22:50:00Z"}, - "topology":{"siteCode":"LAB","rolesObserved":["provider"],"endpoints":[{"endpointId":"provider-local","layer":"provider","hostHandle":"safe:server:lab-provider-01","producerRole":"provider"}]}, - "artifacts":[ - {"artifactId":"rotation-01-current","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.log","pathFingerprint":"synthetic:rotation-current","rotation":{"kind":"current","lineageId":"provider-rotation","fragmentComplete":false},"captureState":"captured","sourceVersion":"9.99.UNKNOWN","collectedUtc":"2026-07-30T22:50:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":127,"relativePath":"evidence/server-provider/provider-local/current/Smsprov.log"}, - {"artifactId":"rotation-02-lo","sourceId":"server-provider","producerRole":"provider","layer":"provider","producerHostHandle":"safe:server:lab-provider-01","endpointId":"provider-local","diagnosticUse":"primary","originalBasename":"Smsprov.log","sanitizedSourcePath":"SYNTHETIC://configured-root/LAB/Logs/Smsprov.lo_","pathFingerprint":"synthetic:rotation-lo","rotation":{"kind":"lo_","lineageId":"provider-rotation","fragmentComplete":false},"captureState":"captured","sourceVersion":"9.99.UNKNOWN","collectedUtc":"2026-07-30T22:50:00Z","encoding":"utf-8","collectionLimit":{"byteLimit":4096,"limitApplied":false},"bytesCopied":295,"relativePath":"evidence/server-provider/provider-local/lo_/Smsprov.lo_"} + "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/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..5e8e6f293 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service.rs @@ -0,0 +1,519 @@ +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::{SccmConfidence, SccmCoverageState, 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 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") +} + +#[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}"); + } +} + +#[derive(Clone, Copy)] +struct ExpectedTransaction { + state: ProviderAdminServiceState, + classification: ProviderAdminServiceClassification, + confidence: SccmConfidence, + ordering: ProviderAdminServiceTimestampOrdering, +} + +fn expected_transactions(scenario: &str) -> &'static [ExpectedTransaction] { + use ProviderAdminServiceClassification as Class; + use ProviderAdminServiceState as State; + use ProviderAdminServiceTimestampOrdering as Ordering; + const SUCCESS: ExpectedTransaction = ExpectedTransaction { + state: State::Succeeded, + classification: Class::Success, + confidence: SccmConfidence::High, + ordering: Ordering::Usable, + }; + const FAILURE: ExpectedTransaction = ExpectedTransaction { + state: State::Failed, + classification: Class::ConfirmedFailure, + confidence: SccmConfidence::High, + ordering: Ordering::Usable, + }; + const BLOCKED: ExpectedTransaction = ExpectedTransaction { + state: State::BlockedOrDeferred, + classification: Class::BlockedOrDeferred, + confidence: SccmConfidence::Moderate, + ordering: Ordering::Usable, + }; + const INCOMPLETE: ExpectedTransaction = ExpectedTransaction { + state: State::Incomplete, + classification: Class::InsufficientEvidence, + confidence: SccmConfidence::Low, + ordering: Ordering::Usable, + }; + const UNORDERED: ExpectedTransaction = ExpectedTransaction { + state: State::Incomplete, + classification: Class::InsufficientEvidence, + confidence: SccmConfidence::Low, + ordering: Ordering::Unusable, + }; + match scenario { + "admin-service-auth-failure" + | "admin-service-backend-failure" + | "provider-authz-denied" + | "provider-query-failure" => &[FAILURE], + "admin-service-success" | "iis-supplemental" | "provider-retry" | "provider-success" => { + &[SUCCESS] + } + "blocked-deferred" => &[BLOCKED], + "contradictory-evidence" | "incomplete" => &[INCOMPLETE], + "privacy-redaction" => &[SUCCESS, SUCCESS], + "provider-timeout" => &[UNORDERED], + _ => &[], + } +} + +#[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}") + }); + let expected = expected_transactions(scenario); + assert_eq!(analysis.transactions.len(), expected.len(), "{scenario}"); + for (transaction, expected) in analysis.transactions.iter().zip(expected) { + assert_eq!(transaction.state, expected.state, "{scenario}"); + assert_eq!( + transaction.classification, expected.classification, + "{scenario}" + ); + assert_eq!(transaction.confidence, expected.confidence, "{scenario}"); + assert_eq!( + transaction.confidence_ceiling, expected.confidence, + "{scenario}" + ); + assert_eq!( + transaction.timestamp_ordering, expected.ordering, + "{scenario}" + ); + assert_eq!(transaction.source_version, "5.00.TEST", "{scenario}"); + assert_eq!( + transaction.producer_role, + match transaction.layer { + ProviderAdminServiceLayer::Provider => SccmRole::Provider, + ProviderAdminServiceLayer::AdminService => SccmRole::AdminService, + }, + "{scenario}" + ); + assert_eq!( + transaction.correlation_eligible, + matches!( + transaction.state, + ProviderAdminServiceState::Succeeded | ProviderAdminServiceState::Failed + ), + "{scenario}" + ); + assert_eq!( + transaction.next_artifact_request.is_some(), + matches!( + transaction.state, + ProviderAdminServiceState::Incomplete + | ProviderAdminServiceState::BlockedOrDeferred + ), + "{scenario}" + ); + } + + let expected_request = matches!( + scenario, + "admin-service-access-denied" + | "admin-service-parse-failed" + | "admin-service-skipped" + | "blocked-deferred" + | "contradictory-evidence" + | "incomplete" + | "provider-source-absent" + | "provider-source-capped" + | "provider-source-unsupported" + | "provider-timeout" + | "rotation-boundary" + ); + assert_eq!( + analysis.artifact_requests.len(), + usize::from(expected_request), + "{scenario}" + ); + + let actual_coverage = public["coverage"] + .as_array() + .expect("public coverage") + .iter() + .map(|coverage| { + json!({ + "artifactId": coverage["artifactId"], + "sourceId": coverage["sourceId"], + "producerRole": coverage["producerRole"], + "state": coverage["state"], + }) + }) + .collect::>(); + assert_eq!( + Value::Array(actual_coverage), + expected_contract["expectedCoverage"], + "{scenario}" + ); + + let actual_transactions = public["transactions"] + .as_array() + .expect("public transactions") + .iter() + .map(|transaction| { + json!({ + "layer": transaction["layer"], + "state": transaction["state"], + "classification": transaction["classification"], + "confidence": transaction["confidence"], + "timestampOrdering": transaction["timestampOrdering"], + "terminalEvidence": transaction["terminalEvidence"], + "lastSuccessfulPhase": transaction["lastSuccessfulPhase"], + }) + }) + .collect::>(); + assert_eq!( + Value::Array(actual_transactions), + expected_contract["expectedTransactions"], + "{scenario}" + ); + + let actual_local_kinds = public["sourceLocalObservations"] + .as_array() + .expect("source-local observations") + .iter() + .map(|observation| observation["kind"].clone()) + .collect::>(); + assert_eq!( + Value::Array(actual_local_kinds), + expected_contract["expectedSourceLocalKinds"], + "{scenario}" + ); + + let actual_request = public["artifactRequests"] + .as_array() + .expect("artifact requests") + .first() + .map(|request| { + json!({ + "logicalId": request["logicalId"], + "role": request["role"], + }) + }) + .unwrap_or(Value::Null); + assert_eq!( + actual_request, expected_contract["expectedArtifactRequest"], + "{scenario}" + ); + } +} + +#[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) + ); + + let contradiction = analyze("contradictory-evidence"); + assert!(contradiction.transactions[0].terminal_evidence); + assert_eq!( + contradiction.transactions[0].state, + ProviderAdminServiceState::Incomplete + ); + 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_exact_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)); +} + +#[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_request.is_some()); + assert!(!transaction.correlation_eligible); +} + +#[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 index 8def5837c..66dd743ef 100644 --- 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 @@ -1,12 +1,8 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use chrono::DateTime; -use cmtraceopen_parser::sccm::{ - classify_artifact_name, normalize_ccm_artifact, SccmArtifact, SccmArtifactFamily, - SccmCoverageState, SccmEvidence, SccmRole, SccmRotation, SccmTimeOrderingState, -}; +use cmtraceopen_parser::sccm::{classify_artifact_name, SccmArtifactFamily, SccmRole}; use serde_json::Value; const SCENARIOS: [&str; 20] = [ @@ -32,3066 +28,165 @@ const SCENARIOS: [&str; 20] = [ "rotation-boundary", ]; -const PROVIDER_CHAIN: [&str; 5] = [ - "receive", - "authenticateOrAuthorize", - "executeProviderOperation", - "respond", - "recordOutcome", -]; - -const ADMIN_SERVICE_CHAIN: [&str; 6] = [ - "receive", - "authenticateOrAuthorize", - "route", - "executeBackendOperation", - "respond", - "recordOutcome", -]; - -const PROVIDER_PROFILE: &str = "provider-server-5.00.test-v1"; -const ADMIN_SERVICE_PROFILE: &str = "admin-service-server-5.00.test-v1"; - 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) -> Result { +fn read_json(scenario: &str, filename: &str) -> Value { let path = corpus_root().join(scenario).join(filename); - let contents = 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())) + 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() -> Result, String> { - let root = corpus_root(); - let scenarios = fs::read_dir(&root) - .map_err(|error| format!("{} is readable: {error}", root.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 has a name") + .expect("scenario directory name") .to_string_lossy() .into_owned() }) }) - .collect::>(); - Ok(scenarios) -} - -fn safe_segmented_path(value: &str, prefix: &str) -> bool { - value.strip_prefix(prefix).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 safe_opaque_token(value: &str, prefix: &str, max_suffix_len: usize) -> bool { - value.strip_prefix(prefix).is_some_and(|suffix| { - !suffix.is_empty() - && suffix.len() <= max_suffix_len - && suffix - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - }) -} - -fn safe_synthetic_lineage(value: &str) -> bool { - !value.is_empty() - && value.len() <= 64 - && value - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') -} - -fn safe_endpoint_id(value: &str) -> bool { - !value.is_empty() - && value.len() <= 64 - && !value.starts_with('-') - && !value.ends_with('-') - && value - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') -} - -fn expected_sanitized_source_path( - source_id: Option<&str>, - rotation: &Value, -) -> Option<&'static str> { - match (source_id, rotation["kind"].as_str()) { - (Some("server-provider"), Some("current")) => { - Some("SYNTHETIC://configured-root/LAB/Logs/Smsprov.log") - } - (Some("server-provider"), Some("lo_")) => { - Some("SYNTHETIC://configured-root/LAB/Logs/Smsprov.lo_") - } - (Some("server-admin-service"), Some("current")) => { - Some("SYNTHETIC://configured-root/LAB/Logs/AdminService.log") - } - (Some("server-admin-service-iis"), Some("current")) => { - Some("SYNTHETIC://scoped-export/LAB/IIS/u_ex_synthetic.log") - } - _ => None, - } -} - -fn expected_relative_path( - source_id: Option<&str>, - endpoint_id: Option<&str>, - rotation: &Value, -) -> Option { - let endpoint_id = endpoint_id.filter(|value| safe_endpoint_id(value))?; - match (source_id, rotation["kind"].as_str()) { - (Some("server-provider"), Some("current")) => Some(format!( - "evidence/server-provider/{endpoint_id}/current/Smsprov.log" - )), - (Some("server-provider"), Some("lo_")) => Some(format!( - "evidence/server-provider/{endpoint_id}/lo_/Smsprov.lo_" - )), - (Some("server-admin-service"), Some("current")) => Some(format!( - "evidence/server-admin-service/{endpoint_id}/current/AdminService.log" - )), - (Some("server-admin-service-iis"), Some("current")) => Some(format!( - "evidence/server-admin-service-iis/{endpoint_id}/current/u_ex_synthetic.log" - )), - _ => None, - } + .collect() } -fn exported_projection_contains_private_shape(value: &Value) -> bool { +fn private_shape(value: &Value) -> bool { match value { Value::String(value) => { - let value = value.to_ascii_lowercase(); - value.chars().any(char::is_control) - || value.contains('@') - || value.contains('\\') - || [ - "bearer ", - "select ", - "delete ", - "insert ", - "update ", - "drop ", - "http://", - "https://", - "ghp_", - "github_pat_", - "eyj", - ] - .iter() - .any(|term| value.contains(term)) - } - Value::Array(values) => values - .iter() - .any(exported_projection_contains_private_shape), - Value::Object(values) => values - .values() - .any(exported_projection_contains_private_shape), + let lowercase = value.to_ascii_lowercase(); + lowercase.contains("bearer ") + || lowercase.contains("select ") + || lowercase.contains("@example") + || lowercase.contains("requestid") + || lowercase.contains("operationhandle") + || lowercase.contains("endpointid") + || lowercase.contains("preparationonly") + || value.contains("99999999-9999-9999-9999-999999999999") + } + Value::Array(values) => values.iter().any(private_shape), + Value::Object(values) => values.iter().any(|(name, value)| { + private_shape(&Value::String(name.clone())) || private_shape(value) + }), _ => false, } } -fn physical_line_count(scenario: &str, relative_path: &str) -> Option { - if !safe_segmented_path(relative_path, "evidence/") { - return None; - } - fs::read_to_string(corpus_root().join(scenario).join(relative_path)) - .ok() - .map(|content| content.lines().count() as u64) -} - -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), - "lo_" 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 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 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", - "RequestId", - "OperationHandle", - "EndpointId", - "Layer", - "ProfileId", - "CallerHandle", - "QueryHandle", - "Authorization", - ]; - let mut fields = BTreeMap::new(); - for (segment_index, segment) in segments.enumerate() { - if segment.starts_with("[redacted:") && segment.ends_with(']') { - continue; - } - let (name, value) = segment - .split_once('=') - .ok_or_else(|| format!("fixture field at segment {segment_index} is not Name=Value"))?; - if !allowed.contains(&name) || value.is_empty() { - return Err(format!( - "unsupported or empty fixture field at segment {segment_index}" - )); - } - if matches!(name, "CallerHandle" | "QueryHandle" | "Authorization") { - if value != "[redacted:sccm-public-message-v1]" { - return Err(format!("raw sensitive fixture field {name}")); - } - continue; - } - if fields.insert(name.to_owned(), value.to_owned()).is_some() { - return Err(format!("duplicate fixture field {name}")); - } - } - Ok(fields) +#[test] +fn corpus_has_the_exact_reviewed_scenario_matrix() { + assert_eq!( + actual_scenarios(), + SCENARIOS.into_iter().map(str::to_owned).collect() + ); } -fn normalized_records( - scenario: &str, - manifest: &Value, -) -> BTreeMap<(String, u32, u32), SccmEvidence> { - let mut records = BTreeMap::new(); - for artifact in manifest["artifacts"] - .as_array() - .expect("manifest artifacts are an array") - { - if artifact["diagnosticUse"] != "primary" - || !matches!( - artifact["captureState"].as_str(), - Some("captured" | "capped") - ) - { - continue; - } - let artifact_id = artifact["artifactId"] - .as_str() - .expect("artifact ID is a string"); - let relative_path = artifact["relativePath"] - .as_str() - .expect("physical artifact has a relative path"); - let content = fs::read_to_string(corpus_root().join(scenario).join(relative_path)) - .expect("fixture evidence is readable UTF-8"); - let model = SccmArtifact { - artifact_id: artifact_id.to_owned(), - display_name: artifact["originalBasename"] - .as_str() - .expect("artifact basename is a string") - .to_owned(), - original_path: None, - host: artifact["producerHostHandle"].as_str().map(str::to_owned), - role: SccmRole::Provider, - 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"]).expect("rotation is valid"), - coverage: coverage_state(artifact["captureState"].as_str().unwrap_or_default()) - .expect("coverage is valid"), - encoding: artifact["encoding"].as_str().map(str::to_owned), - }; - for record in normalize_ccm_artifact(model, &content) { - let start = record - .reference - .line_start - .expect("normalized evidence has a start line"); - let end = record - .reference - .line_end - .expect("normalized evidence has an end line"); +#[test] +fn manifests_are_canonical_server_intake_inputs_with_sealed_roles_and_paths() { + for scenario in SCENARIOS { + let manifest = read_json(scenario, "manifest.json"); + 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"]["rawPaths"], "redacted", "{scenario}"); + assert!(manifest.get("scenario").is_none(), "{scenario}"); + assert!(manifest.get("bundle").is_none(), "{scenario}"); + + for artifact in manifest["artifacts"].as_array().expect("artifact array") { + let source_id = artifact["sourceId"].as_str().expect("source id"); + let role = artifact["producerRole"].as_str().expect("producer role"); + let expected_role = if source_id == "server-provider" { + "provider" + } else { + "adminService" + }; + assert_eq!(role, expected_role, "{scenario}: {source_id}"); + assert_eq!( + artifact["workflowSubject"]["role"], expected_role, + "{scenario}" + ); assert!( - records - .insert((artifact_id.to_owned(), start, end), record) - .is_none(), - "{scenario}: duplicate logical evidence" + artifact["originalPath"] + .as_str() + .is_some_and(|path| path.starts_with("REDACTED_")), + "{scenario}" ); - } - } - records -} - -fn expected_transaction_ids(scenario: &str) -> &'static [&'static str] { - match scenario { - "admin-service-auth-failure" => &[ - "adminService:66666666-6666-6666-6666-666666666666:safe-operation-admin-auth:admin-service-lab", - ], - "admin-service-backend-failure" => &[ - "adminService:77777777-7777-7777-7777-777777777777:safe-operation-admin-backend:admin-service-lab", - ], - "admin-service-success" => &[ - "adminService:55555555-5555-5555-5555-555555555555:safe-operation-admin-read:admin-service-lab", - ], - "blocked-deferred" => &[ - "adminService:eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee:safe-operation-admin-deferred:admin-service-lab", - ], - "contradictory-evidence" => &[ - "provider:dddddddd-dddd-dddd-dddd-dddddddddddd:safe-operation-contradictory:provider-local", - ], - "iis-supplemental" => &[ - "adminService:88888888-8888-8888-8888-888888888888:safe-operation-admin-iis:admin-service-lab", - ], - "incomplete" => &[ - "adminService:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:safe-operation-admin-incomplete:admin-service-lab", - ], - "privacy-redaction" => &[ - "adminService:99999999-9999-9999-9999-999999999999:safe-operation-admin-privacy:admin-service-lab", - "provider:99999999-9999-9999-9999-999999999999:safe-operation-provider-privacy:provider-local", - ], - "provider-authz-denied" => &[ - "provider:22222222-2222-2222-2222-222222222222:safe-operation-update-device:provider-local", - ], - "provider-query-failure" => &[ - "provider:33333333-3333-3333-3333-333333333333:safe-operation-query-device:provider-local", - ], - "provider-retry" => &[ - "provider:cccccccc-cccc-cccc-cccc-cccccccccccc:safe-operation-provider-retry:provider-local", - ], - "provider-success" => &[ - "provider:11111111-1111-1111-1111-111111111111:safe-operation-read-device:provider-local", - ], - "provider-timeout" => &[ - "provider:44444444-4444-4444-4444-444444444444:safe-operation-provider-timeout:provider-local", - ], - "rotation-boundary" => &[], - _ => &[], - } -} - -fn expected_outcomes( - scenario: &str, -) -> &'static [(&'static str, &'static str, &'static str, &'static str)] { - match scenario { - "admin-service-auth-failure" | "admin-service-backend-failure" => { - &[("failed", "confirmedFailure", "high", "high")] - } - "admin-service-success" | "iis-supplemental" | "provider-success" => { - &[("succeeded", "success", "high", "high")] - } - "provider-retry" => &[("succeeded", "success", "high", "high")], - "contradictory-evidence" => &[("incomplete", "insufficientEvidence", "low", "low")], - "privacy-redaction" => &[ - ("succeeded", "success", "high", "high"), - ("succeeded", "success", "high", "high"), - ], - "provider-authz-denied" | "provider-query-failure" => { - &[("failed", "confirmedFailure", "high", "high")] - } - "provider-timeout" | "incomplete" => { - &[("incomplete", "insufficientEvidence", "low", "low")] - } - "blocked-deferred" => &[("incomplete", "insufficientEvidence", "low", "low")], - "rotation-boundary" => &[], - _ => &[], - } -} - -fn expected_public_summaries(scenario: &str) -> &'static [&'static str] { - match scenario { - "admin-service-auth-failure" => &["Admin Service authentication was explicitly rejected."], - "admin-service-backend-failure" => { - &["Admin Service recorded an explicit backend operation failure."] - } - "admin-service-success" => { - &["Admin Service request completed with explicit terminal evidence."] - } - "blocked-deferred" => { - &["Admin Service evidence records a blocked or deferred request without a terminal outcome."] - } - "contradictory-evidence" => &[ - "Provider evidence contains contradictory terminal outcomes for one exact request key.", - ], - "iis-supplemental" => &["Admin Service evidence independently records a terminal success."], - "incomplete" => { - &["Admin Service evidence stops before an explicit response or terminal outcome."] - } - "privacy-redaction" => &[ - "Admin Service privacy fixture completed with redacted public evidence.", - "Provider privacy fixture completed with redacted public evidence.", - ], - "provider-authz-denied" => &["Provider authorization was explicitly denied."], - "provider-query-failure" => &["Provider operation recorded an explicit terminal failure."], - "provider-retry" => &["Provider retry completed with explicit terminal recovery evidence."], - "provider-success" => &["Provider operation completed with explicit terminal evidence."], - "provider-timeout" => &["Provider evidence stops before an explicit terminal outcome."], - "rotation-boundary" => &[], - _ => &[], - } -} - -fn expected_artifact_ids(scenario: &str) -> &'static [&'static str] { - match scenario { - "admin-service-access-denied" => &["coverage-admin-access-denied"], - "admin-service-auth-failure" => &["admin-auth-current"], - "admin-service-backend-failure" => &["admin-backend-current"], - "admin-service-parse-failed" => &["coverage-admin-parse-failed"], - "admin-service-skipped" => &["coverage-admin-skipped"], - "admin-service-success" => &["admin-success-current"], - "blocked-deferred" => &["blocked-deferred-admin-current"], - "contradictory-evidence" => &["contradictory-provider-current"], - "iis-supplemental" => &["admin-iis-current", "iis-supplemental-current"], - "incomplete" => &["incomplete-admin-current"], - "privacy-redaction" => &["privacy-admin-current", "privacy-provider-current"], - "provider-authz-denied" => &["provider-authz-current"], - "provider-query-failure" => &["provider-query-current"], - "provider-retry" => &["provider-retry-current"], - "provider-source-absent" => &["coverage-provider-absent"], - "provider-source-capped" => &["coverage-provider-capped"], - "provider-source-unsupported" => &["coverage-provider-unsupported"], - "provider-success" => &["provider-success-current"], - "provider-timeout" => &["provider-timeout-current"], - "rotation-boundary" => &["rotation-01-current", "rotation-02-lo"], - _ => &[], - } -} - -fn expected_observation_ids(scenario: &str) -> &'static [&'static str] { - match scenario { - "admin-service-auth-failure" => &[ - "admin-auth-01-receive", - "admin-auth-02-rejected", - "admin-auth-03-outcome", - ], - "admin-service-backend-failure" => &[ - "admin-backend-01-receive", - "admin-backend-02-authorize", - "admin-backend-03-route", - "admin-backend-04-execute", - "admin-backend-05-outcome", - ], - "admin-service-success" => &[ - "admin-success-01-receive", - "admin-success-02-authorize", - "admin-success-03-route", - "admin-success-04-backend", - "admin-success-05-respond", - "admin-success-06-outcome", - ], - "blocked-deferred" => &[ - "blocked-deferred-01-receive", - "blocked-deferred-02-deferred", - ], - "contradictory-evidence" => &[ - "contradictory-01-receive", - "contradictory-02-authorize", - "contradictory-03-execute", - "contradictory-04-respond", - "contradictory-05-success", - "contradictory-06-failure", - ], - "iis-supplemental" => &[ - "admin-iis-01-receive", - "admin-iis-02-authorize", - "admin-iis-03-route", - "admin-iis-04-backend", - "admin-iis-05-respond", - "admin-iis-06-outcome", - ], - "incomplete" => &["incomplete-admin-01-receive", "incomplete-admin-02-route"], - "privacy-redaction" => &[ - "privacy-admin-01-receive", - "privacy-admin-02-authorize", - "privacy-admin-03-route", - "privacy-admin-04-backend", - "privacy-admin-05-respond", - "privacy-admin-06-outcome", - "privacy-provider-01-receive", - "privacy-provider-02-authorize", - "privacy-provider-03-execute", - "privacy-provider-04-respond", - "privacy-provider-05-outcome", - ], - "provider-authz-denied" => &[ - "provider-authz-01-receive", - "provider-authz-02-denied", - "provider-authz-03-outcome", - ], - "provider-query-failure" => &[ - "provider-query-01-receive", - "provider-query-02-authorize", - "provider-query-03-execute", - "provider-query-04-outcome", - ], - "provider-retry" => &[ - "provider-retry-01-receive", - "provider-retry-02-authorize", - "provider-retry-03-retryable", - "provider-retry-04-recovered", - "provider-retry-05-respond", - "provider-retry-06-outcome", - ], - "provider-success" => &[ - "provider-success-01-receive", - "provider-success-02-authorize", - "provider-success-03-execute", - "provider-success-04-respond", - "provider-success-05-outcome", - ], - "provider-timeout" => &[ - "provider-timeout-01-receive", - "provider-timeout-02-authorize", - "provider-timeout-03-execute", - ], - "rotation-boundary" => &[], - _ => &[], - } -} - -fn expected_source_local_ids(scenario: &str) -> &'static [&'static str] { - match scenario { - "iis-supplemental" => &["iis-supplemental-01"], - "privacy-redaction" => &["privacy-redaction-admin", "privacy-redaction-provider"], - "rotation-boundary" => &["rotation-fragment-01"], - _ => &[], - } -} - -fn expected_source_local_reasons(scenario: &str) -> &'static [&'static str] { - match scenario { - "iis-supplemental" => &[ - "Scoped IIS evidence is optional context and cannot create an Admin Service transaction.", - ], - "privacy-redaction" => &[ - "Caller and endpoint details remain outside public keys and summaries.", - "Caller, authorization, and query details remain outside public keys and summaries.", - ], - "rotation-boundary" => { - &["Split rotation fragments and an unknown version cannot form an exact request key."] - } - _ => &[], - } -} - -fn expected_artifact_requests(scenario: &str) -> &'static [(&'static str, &'static str)] { - match scenario { - "admin-service-access-denied" => &[( - "server-admin-service", - "Capture bounded Admin Service evidence after access is authorized; access-denied coverage is not an outcome.", - )], - "admin-service-parse-failed" => &[( - "server-admin-service", - "Recapture or repair the bounded Admin Service source; malformed evidence is coverage, not an outcome.", - )], - "admin-service-skipped" => &[( - "server-admin-service", - "Capture the bounded Admin Service source; skipped coverage does not establish a workflow outcome.", - )], - "blocked-deferred" => &[( - "server-admin-service", - "Capture the bounded Admin Service source lineage through an explicit terminal outcome after the blocked or deferred phase.", - )], - "incomplete" => &[( - "server-admin-service", - "Capture the bounded Admin Service source lineage for the exact request key and terminal outcome.", - )], - "provider-timeout" => &[( - "server-provider", - "Capture the bounded Provider source lineage for the exact request key and terminal outcome.", - )], - "provider-source-absent" => &[( - "server-provider", - "Capture the bounded Provider source; absent coverage does not establish a workflow outcome.", - )], - "provider-source-capped" => &[( - "server-provider", - "Recapture the bounded Provider source without truncating the required transaction evidence.", - )], - "provider-source-unsupported" => &[( - "server-provider", - "Use a supported versioned Provider source profile before evaluating request outcomes.", - )], - "contradictory-evidence" => &[( - "server-provider", - "Capture the bounded Provider source lineage for the exact request key and reconcile contradictory terminal outcomes.", - )], - "rotation-boundary" => &[( - "server-provider", - "Capture one bounded complete Provider rotation with known version provenance.", - )], - _ => &[], - } -} - -fn expected_profile_layers(scenario: &str) -> &'static [&'static str] { - match scenario { - "admin-service-access-denied" - | "admin-service-auth-failure" - | "admin-service-backend-failure" - | "admin-service-parse-failed" - | "admin-service-skipped" - | "admin-service-success" - | "blocked-deferred" - | "iis-supplemental" - | "incomplete" => &["adminService"], - "privacy-redaction" => &["adminService", "provider"], - "provider-authz-denied" - | "contradictory-evidence" - | "provider-query-failure" - | "provider-retry" - | "provider-source-absent" - | "provider-source-capped" - | "provider-source-unsupported" - | "provider-success" - | "provider-timeout" - | "rotation-boundary" => &["provider"], - _ => &[], - } -} - -fn known_test_version(value: &str) -> bool { - value - .strip_prefix("5.00.TEST.") - .is_some_and(|suffix| suffix.len() == 4 && suffix.bytes().all(|byte| byte.is_ascii_digit())) -} - -fn safe_version(value: &str) -> bool { - if value.is_empty() || value.len() > 64 { - return false; - } - let segments = value.split('.').collect::>(); - let digits = - |segment: &str| !segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_digit()); - match segments.as_slice() { - ["IIS", "TEST", suffix] => suffix.len() == 4 && digits(suffix), - [major, minor, "UNKNOWN"] => digits(major) && digits(minor), - [major, minor, "TEST", suffix] => { - digits(major) && digits(minor) && suffix.len() == 4 && digits(suffix) - } - [major, minor, build, revision] => { - digits(major) && digits(minor) && digits(build) && digits(revision) - } - _ => false, - } -} - -fn state_chain(layer: &str) -> Option<&'static [&'static str]> { - match layer { - "provider" => Some(&PROVIDER_CHAIN), - "adminService" => Some(&ADMIN_SERVICE_CHAIN), - _ => None, - } -} - -fn profile_for(layer: &str) -> Option<&'static str> { - match layer { - "provider" => Some(PROVIDER_PROFILE), - "adminService" => Some(ADMIN_SERVICE_PROFILE), - _ => None, - } -} - -fn 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", - ], - ) || manifest["sccmManifestVersion"] != 1 - || manifest["proposalOnly"] != true - || manifest["syntheticFixture"] != true - || manifest["scenario"] != scenario - || !object_has_only( - &manifest["bundle"], - &["bundleRole", "workflow", "capturedUtc"], - ) - || manifest["bundle"]["bundleRole"] != "server" - || manifest["bundle"]["workflow"] != "providerAndAdminService" - || DateTime::parse_from_rfc3339( - manifest["bundle"]["capturedUtc"] - .as_str() - .unwrap_or_default(), - ) - .is_err() - { - failures.push("manifest identity or bundle contract changed".to_owned()); - } - - if !object_has_only( - &manifest["topology"], - &["siteCode", "rolesObserved", "endpoints"], - ) || manifest["topology"]["siteCode"] != "LAB" - { - failures.push("manifest topology contains an unsupported shape".to_owned()); - } - let roles = manifest["topology"]["rolesObserved"].as_array(); - if roles.is_none_or(|roles| { - roles.len() != 1 || roles.iter().any(|role| role.as_str() != Some("provider")) - }) { - failures.push("topology roles are not exact strings".to_owned()); - } - - let endpoints = manifest["topology"]["endpoints"].as_array(); - let mut endpoint_layers = BTreeMap::new(); - let mut endpoint_hosts = BTreeMap::new(); - let mut endpoint_ids = Vec::new(); - for endpoint in endpoints.into_iter().flatten() { - if !object_has_only( - endpoint, - &["endpointId", "layer", "hostHandle", "producerRole"], - ) { - failures.push("endpoint contains unsupported fields".to_owned()); - } - let Some(endpoint_id) = endpoint["endpointId"].as_str() else { - failures.push("endpoint ID is not a string".to_owned()); - continue; - }; - let Some(layer) = endpoint["layer"].as_str() else { - failures.push(format!("{endpoint_id}: endpoint layer is not a string")); - continue; - }; - let host_handle = endpoint["hostHandle"].as_str(); - if !safe_endpoint_id(endpoint_id) - || !matches!(layer, "provider" | "adminService") - || endpoint["producerRole"] != "provider" - || host_handle.is_none_or(|value| !safe_opaque_token(value, "safe:server:", 64)) - || endpoint_layers - .insert(endpoint_id.to_owned(), layer.to_owned()) - .is_some() - { - failures.push(format!("{endpoint_id}: invalid endpoint topology")); - } - if let Some(host_handle) = host_handle { - endpoint_hosts.insert(endpoint_id.to_owned(), host_handle.to_owned()); - } - endpoint_ids.push(endpoint_id); - } - let mut sorted_endpoint_ids = endpoint_ids.clone(); - sorted_endpoint_ids.sort_unstable(); - sorted_endpoint_ids.dedup(); - if endpoints.is_none() - || endpoint_ids.is_empty() - || endpoint_ids != sorted_endpoint_ids - || endpoint_layers.len() != endpoint_ids.len() - { - failures.push("endpoint identities are not exact sorted unique strings".to_owned()); - } - - let artifacts = manifest["artifacts"].as_array(); - let mut artifact_ids = Vec::new(); - let mut artifact_sources = BTreeMap::new(); - let mut artifact_physical_state = BTreeMap::new(); - let mut fingerprints = BTreeSet::new(); - let mut destinations = BTreeSet::new(); - let mut sanitized_sources = BTreeSet::new(); - let mut artifact_endpoint_ids = BTreeSet::new(); - let mut physical_line_counts = BTreeMap::new(); - let mut artifact_rotation_provenance = BTreeMap::new(); - for artifact in artifacts.into_iter().flatten() { - if !object_has_only( - artifact, - &[ - "artifactId", - "sourceId", - "producerRole", + for obsolete in [ "layer", - "producerHostHandle", "endpointId", "diagnosticUse", - "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 unsupported fields".to_owned()); - } - let Some(artifact_id) = artifact["artifactId"].as_str() else { - failures.push("artifact ID is not a string".to_owned()); - continue; - }; - if artifact_id.is_empty() { - failures.push("artifact ID is empty".to_owned()); - } - artifact_ids.push(artifact_id); - let source_id = artifact["sourceId"].as_str(); - let layer = artifact["layer"].as_str(); - let basename = artifact["originalBasename"].as_str(); - let diagnostic_use = artifact["diagnosticUse"].as_str(); - let endpoint_id = artifact["endpointId"].as_str(); - let producer_host = artifact["producerHostHandle"].as_str(); - let sanitized_source_path = artifact["sanitizedSourcePath"].as_str(); - let path_fingerprint = artifact["pathFingerprint"].as_str(); - let source_tuple = (layer, source_id, basename, diagnostic_use); - if !matches!( - source_tuple, - ( - Some("provider"), - Some("server-provider"), - Some("Smsprov.log"), - Some("primary") - ) | ( - Some("adminService"), - Some("server-admin-service"), - Some("AdminService.log"), - Some("primary") - ) | ( - Some("supplementalIis"), - Some("server-admin-service-iis"), - Some("u_ex_synthetic.log"), - Some("supplementalOnly") - ) - ) { - failures.push(format!("{artifact_id}: unsupported source/layer tuple")); - } - if artifact["producerRole"] != "provider" - || producer_host.is_none_or(|value| !safe_opaque_token(value, "safe:server:", 64)) - || endpoint_id - .and_then(|id| endpoint_hosts.get(id)) - .map(String::as_str) - != producer_host - || DateTime::parse_from_rfc3339(artifact["collectedUtc"].as_str().unwrap_or_default()) - .is_err() - || sanitized_source_path - != expected_sanitized_source_path(source_id, &artifact["rotation"]) - || sanitized_source_path - .map(str::to_ascii_lowercase) - .is_none_or(|value| !sanitized_sources.insert(value)) - || path_fingerprint.is_none_or(|value| !safe_opaque_token(value, "synthetic:", 96)) - || path_fingerprint - .map(str::to_ascii_lowercase) - .is_none_or(|value| !fingerprints.insert(value)) - || rotation(&artifact["rotation"]).is_none() - || artifact["rotation"]["lineageId"] - .as_str() - .is_none_or(|value| !safe_synthetic_lineage(value)) - || coverage_state(artifact["captureState"].as_str().unwrap_or_default()).is_none() - { - failures.push(format!("{artifact_id}: invalid typed provenance")); - } - if let Some(endpoint_id) = endpoint_id { - artifact_endpoint_ids.insert(endpoint_id); - } - let expected_endpoint_layer = endpoint_id.and_then(|id| endpoint_layers.get(id)); - let endpoint_matches = match layer { - Some("supplementalIis") => { - expected_endpoint_layer.map(String::as_str) == Some("adminService") - } - Some(layer) => expected_endpoint_layer.map(String::as_str) == Some(layer), - None => false, - }; - if !endpoint_matches { - failures.push(format!("{artifact_id}: incompatible endpoint topology")); - } - let state = artifact["captureState"].as_str(); - match state { - Some("captured" | "capped" | "parseFailed") => { - let relative_path = artifact["relativePath"].as_str(); - let byte_limit = artifact["collectionLimit"]["byteLimit"].as_u64(); - let limit_applied = artifact["collectionLimit"]["limitApplied"].as_bool(); - let bytes_copied = artifact["bytesCopied"].as_u64(); - let limit_matches_state = match state { - Some("captured") => limit_applied == Some(false), - Some("capped") => { - limit_applied == Some(true) - && bytes_copied.is_some() - && bytes_copied == byte_limit - } - Some("parseFailed") => limit_applied == Some(false), - _ => false, - }; - if relative_path.is_none_or(|value| !safe_segmented_path(value, "evidence/")) - || relative_path - != expected_relative_path(source_id, endpoint_id, &artifact["rotation"]) - .as_deref() - || relative_path - .map(str::to_ascii_lowercase) - .is_none_or(|value| !destinations.insert(value)) - || bytes_copied.is_none() - || artifact["encoding"] != "utf-8" - || byte_limit.is_none_or(|limit| limit == 0) - || bytes_copied - .zip(byte_limit) - .is_none_or(|(copied, limit)| copied > limit) - || !limit_matches_state - || artifact["rotation"]["fragmentComplete"].as_bool().is_none() - || artifact["sourceVersion"] - .as_str() - .is_none_or(|value| !safe_version(value)) - { - failures.push(format!("{artifact_id}: invalid physical provenance")); - } - if let Some(line_count) = - relative_path.and_then(|path| physical_line_count(scenario, path)) - { - physical_line_counts.insert(artifact_id.to_owned(), line_count); - } - } - 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 coverage invents file provenance" - )); - } - } - _ => {} - } - if let (Some(source_id), Some(layer)) = (source_id, layer) { - artifact_sources.insert( - artifact_id.to_owned(), - (source_id.to_owned(), layer.to_owned()), - ); - } - if let ( - Some(source_id), - Some(endpoint_id), - Some(producer_host), - Some(lineage_id), - Some(kind), - ) = ( - source_id, - endpoint_id, - producer_host, - artifact["rotation"]["lineageId"].as_str(), - artifact["rotation"]["kind"].as_str(), - ) { - artifact_rotation_provenance.insert( - artifact_id.to_owned(), - ( - source_id.to_owned(), - endpoint_id.to_owned(), - producer_host.to_owned(), - lineage_id.to_owned(), - kind.to_owned(), - ), - ); - } - artifact_physical_state.insert( - artifact_id.to_owned(), - ( - state.unwrap_or_default().to_owned(), - artifact["rotation"]["fragmentComplete"].as_bool(), - ), - ); - } - let mut sorted_artifact_ids = artifact_ids.clone(); - sorted_artifact_ids.sort_unstable(); - sorted_artifact_ids.dedup(); - if artifacts.is_none() - || artifact_ids.is_empty() - || artifact_ids != sorted_artifact_ids - || artifact_ids != expected_artifact_ids(scenario) - || artifact_sources.len() != artifact_ids.len() - || artifact_endpoint_ids != endpoint_ids.iter().copied().collect::>() - { - failures.push("artifact IDs are not exact sorted unique strings".to_owned()); - } - - if !object_has_only( - expected, - &[ - "contractState", - "workflow", - "scenario", - "profiles", - "coverage", - "transactions", - "sourceLocalObservations", - "artifactRequests", - "crossSideCausalClaims", - ], - ) || expected["contractState"] != "preparationOnlyReviewedDependenciesAvailable" - || expected["workflow"] != "providerAndAdminService" - || expected["scenario"] != scenario - || expected["crossSideCausalClaims"] != Value::Array(Vec::new()) - { - failures.push("expected output loses its preparation boundary".to_owned()); - } - if exported_projection_contains_private_shape(manifest) - || exported_projection_contains_private_shape(expected) - { - failures.push("exported projection contains a private raw shape".to_owned()); - } - - let profiles = expected["profiles"].as_array(); - let mut profile_layers = Vec::new(); - for profile in profiles.into_iter().flatten() { - if !object_has_only(profile, &["layer", "selectionState", "profileId"]) { - failures.push("profile contains unsupported fields".to_owned()); - } - let layer = profile["layer"].as_str(); - let selection = profile["selectionState"].as_str(); - let profile_id = profile["profileId"].as_str(); - let versions = manifest["artifacts"] - .as_array() - .into_iter() - .flatten() - .filter(|artifact| { - artifact["layer"].as_str() == layer && artifact["diagnosticUse"] == "primary" - }) - .filter_map(|artifact| artifact["sourceVersion"].as_str()) - .collect::>(); - let versions_are_known = - !versions.is_empty() && versions.iter().all(|version| known_test_version(version)); - let versions_are_unknown = !versions.is_empty() && !versions_are_known; - if layer.is_none() - || !matches!(selection, Some("selectedSynthetic" | "unknownVersion")) - || match selection { - Some("selectedSynthetic") => { - layer.and_then(profile_for) != profile_id || !versions_are_known - } - Some("unknownVersion") => profile_id.is_some() || !versions_are_unknown, - _ => true, - } - { - failures.push("profile selection is not exact and versioned".to_owned()); - } - if let Some(layer) = layer { - profile_layers.push(layer); - } - } - let mut sorted_profile_layers = profile_layers.clone(); - sorted_profile_layers.sort_unstable(); - sorted_profile_layers.dedup(); - if profiles.is_none() - || profile_layers.is_empty() - || profile_layers != sorted_profile_layers - || profile_layers != expected_profile_layers(scenario) - || profile_layers - .iter() - .any(|layer| !matches!(*layer, "provider" | "adminService" | "supplementalIis")) - { - failures.push("profile layers are not exact sorted unique strings".to_owned()); - } - - let coverage = expected["coverage"].as_array(); - let mut coverage_projection = Vec::new(); - for row in coverage.into_iter().flatten() { - if !object_has_only(row, &["artifactId", "state", "sourceId", "layer"]) { - failures.push("coverage row contains unsupported fields".to_owned()); - } - let artifact_id = row["artifactId"].as_str(); - let state = row["state"].as_str(); - let source_id = row["sourceId"].as_str(); - let layer = row["layer"].as_str(); - if artifact_id.is_none() - || state.and_then(coverage_state).is_none() - || artifact_id - .and_then(|id| artifact_sources.get(id)) - .is_none_or(|(expected_source, expected_layer)| { - Some(expected_source.as_str()) != source_id - || Some(expected_layer.as_str()) != layer - }) - { - failures.push("coverage row is not bound to a manifest artifact".to_owned()); - } - if let Some(artifact_id) = artifact_id { - coverage_projection.push(artifact_id); - } - } - let manifest_coverage = manifest["artifacts"] - .as_array() - .into_iter() - .flatten() - .filter_map(|artifact| { - Some(( - artifact["artifactId"].as_str()?, - artifact["captureState"].as_str()?, - )) - }) - .collect::>(); - let expected_coverage = coverage - .into_iter() - .flatten() - .filter_map(|row| Some((row["artifactId"].as_str()?, row["state"].as_str()?))) - .collect::>(); - if coverage.is_none() - || coverage_projection != artifact_ids - || manifest_coverage != expected_coverage - { - failures.push("coverage is not the exact sorted manifest projection".to_owned()); - } - - let transactions = expected["transactions"].as_array(); - let mut transaction_ids = Vec::new(); - let mut all_observation_ids = Vec::new(); - let mut outcomes = Vec::new(); - let mut public_summaries = Vec::new(); - for transaction in transactions.into_iter().flatten() { - if !object_has_only( - transaction, - &[ - "transactionId", - "layer", - "key", - "topologyCompatibility", - "timestampOrdering", - "state", - "classification", - "confidence", - "confidenceCeiling", - "terminalEvidence", - "coverageGapArtifactIds", - "publicSummary", - "observations", - ], - ) || !object_has_only( - &transaction["key"], - &[ - "requestId", - "operationHandle", - "endpointId", - "confidence", - "extractionProfileId", - ], - ) { - failures.push("transaction contains unsupported fields".to_owned()); - } - let transaction_id = transaction["transactionId"].as_str().unwrap_or_default(); - transaction_ids.push(transaction_id); - outcomes.push(( - transaction["state"].as_str().unwrap_or_default(), - transaction["classification"].as_str().unwrap_or_default(), - transaction["confidence"].as_str().unwrap_or_default(), - transaction["confidenceCeiling"] - .as_str() - .unwrap_or_default(), - )); - public_summaries.push(transaction["publicSummary"].as_str().unwrap_or_default()); - let layer = transaction["layer"].as_str().unwrap_or_default(); - let key = &transaction["key"]; - let request_id = key["requestId"].as_str(); - let operation = key["operationHandle"].as_str(); - let endpoint_id = key["endpointId"].as_str(); - let derived_id = format!( - "{layer}:{}:{}:{}", - request_id.unwrap_or_default().to_ascii_lowercase(), - operation.unwrap_or_default(), - endpoint_id.unwrap_or_default() - ); - if state_chain(layer).is_none() - || request_id.is_none_or(str::is_empty) - || operation.is_none_or(|value| !value.starts_with("safe-operation-")) - || endpoint_id.is_none_or(str::is_empty) - || key["confidence"] != "exact" - || key["extractionProfileId"].as_str() != profile_for(layer) - || transaction_id != derived_id - || endpoint_id - .and_then(|id| endpoint_layers.get(id)) - .map(String::as_str) - != Some(layer) - || transaction["topologyCompatibility"] != "exact" - || !matches!( - transaction["timestampOrdering"].as_str(), - Some("usable" | "unusableInvalidOffset") - ) - || !matches!( - transaction["confidenceCeiling"].as_str(), - Some("high" | "medium" | "low") - ) - { - failures.push("transaction identity/key/topology is not exact".to_owned()); - } - 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 - || gap_ids.iter().any(|id| !artifact_sources.contains_key(*id)) - { - failures.push("coverage gaps are not exact sorted manifest artifact IDs".to_owned()); - } - if transaction["confidence"] == "high" - && (transaction["confidenceCeiling"] != "high" - || transaction["timestampOrdering"] != "usable" - || transaction["terminalEvidence"] != true - || !gap_ids.is_empty()) - { - failures.push("high confidence bypasses time/terminal/coverage gates".to_owned()); - } - let evidence_confidence_ceiling = if transaction["timestampOrdering"] == "usable" - && transaction["terminalEvidence"] == true - && gap_ids.is_empty() - && transaction["state"] != "incomplete" - { - "high" - } else { - "low" - }; - if transaction["confidenceCeiling"] != evidence_confidence_ceiling { - failures.push( - "confidence ceiling diverges from time/terminal/coverage/completeness gates" - .to_owned(), - ); - } - if transaction["publicSummary"].as_str().is_none_or(|summary| { - summary.is_empty() - || [ - "@", - "bearer", - "select ", - "http://", - "https://", - "/adminservice", - ] - .iter() - .any(|term| summary.to_ascii_lowercase().contains(term)) - }) { - failures.push("public transaction summary is unsafe or empty".to_owned()); - } - - let observations = transaction["observations"].as_array(); - let mut observation_ids = Vec::new(); - let mut prior_phase = 0usize; - let mut cited_terminal = false; - let mut terminal_dispositions = BTreeSet::new(); - let mut phase_dispositions = Vec::new(); - let mut cited_references = BTreeSet::new(); - for observation in observations.into_iter().flatten() { - if !object_has_only( - observation, - &[ - "observationId", - "phase", - "disposition", - "terminal", - "evidence", - ], - ) { - failures.push("transaction observation contains unsupported fields".to_owned()); - } - let observation_id = observation["observationId"].as_str(); - let phase = observation["phase"].as_str(); - let disposition = observation["disposition"].as_str(); - let chain = state_chain(layer).unwrap_or_default(); - let phase_index = phase.and_then(|phase| chain.iter().position(|item| *item == phase)); - if observation_id.is_none_or(str::is_empty) - || phase_index.is_none() - || phase_index.is_some_and(|index| index < prior_phase) - || !matches!( - disposition, - Some("succeeded" | "failed" | "retryableFailure" | "pending") - ) - || observation["terminal"].as_bool().is_none() - { - failures.push("transaction observation is not exact/monotonic".to_owned()); - } - if let Some(index) = phase_index { - prior_phase = index; - } - let terminal = observation["terminal"].as_bool().unwrap_or(false); - if terminal && phase != Some("recordOutcome") { - failures.push( - "terminal evidence is not bound to the source-specific outcome phase" - .to_owned(), - ); - } - if terminal && phase == Some("recordOutcome") { - cited_terminal = true; - if let Some(disposition) = observation["disposition"].as_str() { - terminal_dispositions.insert(disposition); - } - } - if let (Some(phase), Some(disposition)) = (phase, disposition) { - phase_dispositions.push((phase, disposition, terminal)); - } - if let Some(observation_id) = observation_id { - observation_ids.push(observation_id); - all_observation_ids.push(observation_id); - } - 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() { - let start = reference["startLine"] - .as_u64() - .and_then(|line| u32::try_from(line).ok()); - let end = reference["endLine"] - .as_u64() - .and_then(|line| u32::try_from(line).ok()); - let physical_range_is_valid = reference["artifactId"] - .as_str() - .zip(start) - .zip(end) - .is_some_and(|((artifact_id, start), end)| { - start > 0 - && end >= start - && physical_line_counts - .get(artifact_id) - .is_some_and(|line_count| u64::from(end) <= *line_count) - }); - if !object_has_only(reference, &["artifactId", "startLine", "endLine"]) - || reference["artifactId"] - .as_str() - .is_none_or(|id| !artifact_sources.contains_key(id)) - || reference["artifactId"].as_str().is_some_and(|id| { - artifact_sources - .get(id) - .is_none_or(|(_, artifact_layer)| artifact_layer != layer) - }) - || reference["artifactId"].as_str().is_some_and(|id| { - artifact_physical_state - .get(id) - .is_none_or(|(state, complete)| { - state != "captured" || *complete != Some(true) - }) - }) - || !physical_range_is_valid - { - failures.push("transaction evidence reference is malformed".to_owned()); - } - if let (Some(artifact_id), Some(start), Some(end)) = - (reference["artifactId"].as_str(), start, end) - { - if !cited_references.insert((artifact_id, start, end)) { - failures.push("transaction evidence reference is duplicated".to_owned()); - } - } - } - } - let mut sorted_observation_ids = observation_ids.clone(); - sorted_observation_ids.sort_unstable(); - sorted_observation_ids.dedup(); - if observations.is_none() - || observation_ids.is_empty() - || observation_ids != sorted_observation_ids - { - failures - .push("transaction observation identities are not sorted and unique".to_owned()); - } - if transaction["terminalEvidence"].as_bool() != Some(cited_terminal) { - failures.push("transaction terminality is not citation-derived".to_owned()); - } - let terminal_outcome_matches = match transaction["state"].as_str() { - Some("succeeded") => { - terminal_dispositions == BTreeSet::from(["succeeded"]) - && transaction["classification"] == "success" - } - Some("failed") => { - terminal_dispositions == BTreeSet::from(["failed"]) - && transaction["classification"] == "confirmedFailure" - } - Some("incomplete") if cited_terminal => { - terminal_dispositions.len() > 1 - && transaction["classification"] == "insufficientEvidence" - } - Some("incomplete") => { - terminal_dispositions.is_empty() - && transaction["classification"] == "insufficientEvidence" - } - _ => false, - }; - if !terminal_outcome_matches { - failures.push( - "terminal outcome disposition does not agree with conservative state/classification" - .to_owned(), - ); - } - if phase_dispositions - .iter() - .any(|(_, disposition, terminal)| *disposition == "failed" && !terminal) - && transaction["state"] != "failed" - { - failures.push("nonterminal failure does not agree with the declared state".to_owned()); - } - if phase_dispositions.iter().any(|(_, disposition, terminal)| { - *disposition == "retryableFailure" && (scenario != "provider-retry" || *terminal) - }) { - failures.push("retryable failure is outside the closed recovery contract".to_owned()); - } - for (index, (phase, disposition, _)) in phase_dispositions.iter().enumerate() { - if *disposition == "retryableFailure" - && !phase_dispositions[index + 1..].iter().any( - |(later_phase, later_disposition, _)| { - later_phase == phase && *later_disposition == "succeeded" - }, - ) - { - failures.push( - "retryable failure lacks a later same-phase successful recovery".to_owned(), - ); - } - } - if phase_dispositions.iter().any(|(_, disposition, terminal)| { - *disposition == "pending" - && (*terminal || transaction["state"].as_str() != Some("incomplete")) - }) { - failures.push("pending disposition is outside an incomplete transaction".to_owned()); - } - if scenario == "provider-retry" { - let retry_sequence = phase_dispositions - .iter() - .filter_map(|(phase, disposition, _)| { - (*phase == "executeProviderOperation").then_some(*disposition) - }) - .collect::>(); - if retry_sequence != ["retryableFailure", "succeeded"] { - failures.push( - "provider retry lacks one explicit retryable failure followed by recovery" - .to_owned(), + ] { + assert!( + artifact.get(obsolete).is_none(), + "{scenario}: obsolete {obsolete}" ); } - } - if transaction["state"] == "succeeded" { - let success_sequence_is_exact = match layer { - "provider" if scenario == "provider-retry" => { - phase_dispositions - == [ - ("receive", "succeeded", false), - ("authenticateOrAuthorize", "succeeded", false), - ("executeProviderOperation", "retryableFailure", false), - ("executeProviderOperation", "succeeded", false), - ("respond", "succeeded", false), - ("recordOutcome", "succeeded", true), - ] - } - "provider" => { - phase_dispositions - == [ - ("receive", "succeeded", false), - ("authenticateOrAuthorize", "succeeded", false), - ("executeProviderOperation", "succeeded", false), - ("respond", "succeeded", false), - ("recordOutcome", "succeeded", true), - ] - } - "adminService" => { - phase_dispositions - == [ - ("receive", "succeeded", false), - ("authenticateOrAuthorize", "succeeded", false), - ("route", "succeeded", false), - ("executeBackendOperation", "succeeded", false), - ("respond", "succeeded", false), - ("recordOutcome", "succeeded", true), - ] - } - _ => false, - }; - if !success_sequence_is_exact { - failures.push( - "successful transaction omits or reorders a required layer phase".to_owned(), - ); - } - } - if transaction["state"] == "failed" { - let failure_sequence_is_exact = match scenario { - "provider-authz-denied" | "admin-service-auth-failure" => { - phase_dispositions - == [ - ("receive", "succeeded", false), - ("authenticateOrAuthorize", "failed", false), - ("recordOutcome", "failed", true), - ] - } - "provider-query-failure" => { - phase_dispositions - == [ - ("receive", "succeeded", false), - ("authenticateOrAuthorize", "succeeded", false), - ("executeProviderOperation", "failed", false), - ("recordOutcome", "failed", true), - ] - } - "admin-service-backend-failure" => { - phase_dispositions - == [ - ("receive", "succeeded", false), - ("authenticateOrAuthorize", "succeeded", false), - ("route", "succeeded", false), - ("executeBackendOperation", "failed", false), - ("recordOutcome", "failed", true), - ] - } - _ => false, - }; - if !failure_sequence_is_exact { - failures.push( - "phase-specific failure omits or reorders its exact failed phase".to_owned(), - ); - } - } - } - let mut sorted_transaction_ids = transaction_ids.clone(); - sorted_transaction_ids.sort_unstable(); - sorted_transaction_ids.dedup(); - if transactions.is_none() - || transaction_ids != sorted_transaction_ids - || transaction_ids != expected_transaction_ids(scenario) - || all_observation_ids != expected_observation_ids(scenario) - || outcomes != expected_outcomes(scenario) - || public_summaries != expected_public_summaries(scenario) - { - failures.push("transaction identity/cardinality matrix changed".to_owned()); - } - - let source_local = expected["sourceLocalObservations"].as_array(); - let mut source_local_ids = Vec::new(); - let mut source_local_reasons = Vec::new(); - for observation in source_local.into_iter().flatten() { - if !object_has_only( - observation, - &[ - "observationId", - "kind", - "layer", - "reason", - "correlationEligible", - "evidence", - ], - ) || !matches!( - observation["kind"].as_str(), - Some("supplementalOnly" | "privacyRedacted" | "rotationFragment") - ) || !matches!( - observation["layer"].as_str(), - Some("provider" | "adminService" | "supplementalIis") - ) || observation["reason"].as_str().is_none_or(str::is_empty) - || observation["correlationEligible"] != false - { - failures.push("source-local observation is not closed and noncorrelatable".to_owned()); - } - if let Some(reason) = observation["reason"].as_str() { - source_local_reasons.push(reason); - } - if let Some(id) = observation["observationId"].as_str() { - if id.is_empty() { - failures.push("source-local observation ID is empty".to_owned()); - } - source_local_ids.push(id); - } else { - failures.push("source-local observation ID is not a string".to_owned()); - } - let references = observation["evidence"].as_array(); - if references.is_none_or(Vec::is_empty) { - failures.push("source-local observation lacks evidence".to_owned()); - } - let mut cited_references = BTreeSet::new(); - let mut cited_artifact_ids = Vec::new(); - for reference in references.into_iter().flatten() { - let artifact_id = reference["artifactId"].as_str(); - let start = reference["startLine"].as_u64(); - let end = reference["endLine"].as_u64(); - let physical_range_is_valid = - artifact_id - .zip(start) - .zip(end) - .is_some_and(|((artifact_id, start), end)| { - start > 0 - && end >= start - && physical_line_counts - .get(artifact_id) - .is_some_and(|line_count| end <= *line_count) - }); - if !object_has_only(reference, &["artifactId", "startLine", "endLine"]) - || artifact_id.is_none_or(|id| !artifact_sources.contains_key(id)) - || artifact_id.is_some_and(|id| { - let observation_layer = observation["layer"].as_str().unwrap_or_default(); - artifact_sources - .get(id) - .is_none_or(|(_, artifact_layer)| artifact_layer != observation_layer) - }) - || !physical_range_is_valid - { - failures.push("source-local evidence reference is malformed".to_owned()); - } - if let (Some(artifact_id), Some(start), Some(end)) = (artifact_id, start, end) { - cited_artifact_ids.push(artifact_id); - if !cited_references.insert((artifact_id, start, end)) { - failures.push("source-local evidence reference is duplicated".to_owned()); - } - } - } - if observation["kind"] == "rotationFragment" { - let mut shared_lineage = BTreeSet::new(); - let mut rotation_kinds = BTreeSet::new(); - for artifact_id in &cited_artifact_ids { - if let Some((source_id, endpoint_id, host, lineage_id, kind)) = - artifact_rotation_provenance.get(*artifact_id) - { - shared_lineage.insert((source_id, endpoint_id, host, lineage_id)); - rotation_kinds.insert(kind.as_str()); - } - } - if cited_artifact_ids.len() != 2 - || shared_lineage.len() != 1 - || rotation_kinds.len() != 2 - || !rotation_kinds.contains("current") - || !rotation_kinds.contains("lo_") - { - failures.push( - "split rotation evidence does not share source/endpoint/host/lineage" - .to_owned(), - ); - } - } - } - let mut sorted_source_local_ids = source_local_ids.clone(); - sorted_source_local_ids.sort_unstable(); - sorted_source_local_ids.dedup(); - if source_local.is_none() - || source_local_ids != sorted_source_local_ids - || source_local_ids != expected_source_local_ids(scenario) - || source_local_reasons != expected_source_local_reasons(scenario) - { - failures.push("source-local observation IDs are not sorted and unique".to_owned()); - } - - let requests = expected["artifactRequests"].as_array(); - let mut requested_artifacts = Vec::new(); - for request in requests.into_iter().flatten() { - if !object_has_only(request, &["logicalArtifactId", "reason"]) - || !matches!( - request["logicalArtifactId"].as_str(), - Some("server-provider" | "server-admin-service" | "server-admin-service-iis") - ) - || request["reason"].as_str().is_none_or(str::is_empty) - { - failures.push("artifact request is not exact and bounded".to_owned()); - } - if let (Some(source), Some(reason)) = ( - request["logicalArtifactId"].as_str(), - request["reason"].as_str(), - ) { - requested_artifacts.push((source, reason)); - } - } - let mut sorted_requested_artifacts = requested_artifacts.clone(); - sorted_requested_artifacts.sort_unstable(); - sorted_requested_artifacts.dedup(); - if requests.is_none() - || requested_artifacts != sorted_requested_artifacts - || requested_artifacts != expected_artifact_requests(scenario) - { - failures.push("artifact requests are not exact sorted bounded contracts".to_owned()); - } - failures -} - -fn schema_failures_with_records( - scenario: &str, - manifest: &Value, - expected: &Value, - records: &BTreeMap<(String, u32, u32), SccmEvidence>, -) -> Vec { - let mut failures = schema_failures(scenario, manifest, expected); - failures.extend(uncited_transaction_record_failures(expected, records)); - failures -} - -fn uncited_transaction_record_failures( - expected: &Value, - records: &BTreeMap<(String, u32, u32), SccmEvidence>, -) -> Vec { - let mut failures = Vec::new(); - for transaction in expected["transactions"].as_array().into_iter().flatten() { - let layer = transaction["layer"].as_str(); - let key = &transaction["key"]; - let cited = transaction["observations"] - .as_array() - .into_iter() - .flatten() - .flat_map(|observation| observation["evidence"].as_array().into_iter().flatten()) - .filter_map(|reference| { - Some(( - reference["artifactId"].as_str()?.to_owned(), - u32::try_from(reference["startLine"].as_u64()?).ok()?, - u32::try_from(reference["endLine"].as_u64()?).ok()?, - )) - }) - .collect::>(); - - for (record_key, record) in records { - let Ok(fields) = parse_fixture_fields(&record.message) else { - continue; - }; - let matches_exact_key = [ - ("RequestId", key["requestId"].as_str()), - ("OperationHandle", key["operationHandle"].as_str()), - ("EndpointId", key["endpointId"].as_str()), - ("Layer", layer), - ("ProfileId", key["extractionProfileId"].as_str()), - ] - .iter() - .all(|(field, value)| fields.get(*field).map(String::as_str) == *value); - if matches_exact_key && !cited.contains(record_key) { - failures.push(format!( - "{}: admitted exact-key logical record {}:{}-{} is uncited", - transaction["transactionId"].as_str().unwrap_or(""), - record_key.0, - record_key.1, - record_key.2 - )); + let state = artifact["captureState"].as_str().expect("coverage state"); + if matches!(state, "captured" | "capped" | "parseFailed") { + let relative = artifact["relativePath"].as_str().expect("relative path"); + let payload = corpus_root().join(scenario).join(Path::new(relative)); + let size = fs::metadata(&payload) + .unwrap_or_else(|error| panic!("{}: {error}", payload.display())) + .len(); + assert_eq!(artifact["bytesCopied"].as_u64(), Some(size), "{scenario}"); + } else { + assert!(artifact.get("relativePath").is_none(), "{scenario}"); + assert_eq!(artifact["bytesCopied"], 0, "{scenario}"); } } } - failures -} - -#[test] -fn provider_and_admin_service_scenario_matrix_is_exact() { - let actual = actual_scenarios().expect("provider/Admin Service fixture root exists"); - let expected = SCENARIOS - .into_iter() - .map(str::to_owned) - .collect::>(); - - assert_eq!(actual, expected); } #[test] -fn preparation_state_records_reviewed_dependencies_as_available() { - let mut stale = Vec::new(); +fn expected_contracts_are_final_privacy_safe_semantic_oracles() { for scenario in SCENARIOS { - let expected = - read_json(scenario, "expected.json").unwrap_or_else(|error| panic!("{error}")); - if expected["contractState"] != "preparationOnlyReviewedDependenciesAvailable" { - stale.push(scenario); - } + let expected = read_json(scenario, "expected.json"); + assert_eq!(expected["fixtureContractVersion"], 1, "{scenario}"); + assert_eq!( + expected["workflow"], "providerAndAdminService", + "{scenario}" + ); + assert_eq!(expected["scenario"], scenario, "{scenario}"); + assert!( + !private_shape(&expected), + "{scenario}: private or obsolete expected shape" + ); + assert!(expected["expectedCoverage"].is_array(), "{scenario}"); + assert!(expected["expectedTransactions"].is_array(), "{scenario}"); + assert!( + expected["expectedSourceLocalKinds"].is_array(), + "{scenario}" + ); } - - assert!( - stale.is_empty(), - "preparation outputs still claim reviewed #318/#335 dependencies are pending: {stale:#?}" - ); } #[test] -fn provider_and_admin_service_sources_are_layered_and_role_exact() { +fn source_catalog_distinguishes_provider_and_admin_service() { let provider = classify_artifact_name("Smsprov.log", SccmRole::Provider); - assert_eq!(provider.family, SccmArtifactFamily::Provider); - assert!(provider.uses_ccm_records); assert!(provider.supported_for_diagnosis); + assert_eq!(provider.family, SccmArtifactFamily::Provider); + assert_eq!(provider.role, SccmRole::Provider); - let admin_service = classify_artifact_name("AdminService.log", SccmRole::Provider); - assert_eq!(admin_service.family, SccmArtifactFamily::AdminService); - assert!(admin_service.uses_ccm_records); - assert!(admin_service.supported_for_diagnosis); + 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 role in [ + for wrong_role in [ SccmRole::SiteServer, SccmRole::ManagementPoint, - SccmRole::AdminService, + SccmRole::DistributionPoint, + SccmRole::SoftwareUpdatePoint, + SccmRole::WsUs, + SccmRole::Provider, ] { - let wrong_provider = classify_artifact_name("Smsprov.log", role.clone()); - let wrong_admin = classify_artifact_name("AdminService.log", role); - assert!(matches!( - wrong_provider.family, - SccmArtifactFamily::Unknown(_) - )); - assert!(!wrong_provider.supported_for_diagnosis); - assert!(matches!(wrong_admin.family, SccmArtifactFamily::Unknown(_))); - assert!(!wrong_admin.supported_for_diagnosis); + let wrong = classify_artifact_name("AdminService.log", wrong_role); + assert!(!wrong.supported_for_diagnosis); } - - let iis = classify_artifact_name("u_ex_synthetic.log", SccmRole::Provider); - assert!(matches!(iis.family, SccmArtifactFamily::Unknown(_))); - assert!(!iis.uses_ccm_records); - assert!(!iis.supported_for_diagnosis); -} - -#[test] -fn provider_and_admin_service_contracts_are_closed_and_coverage_exact() { - let mut failures = Vec::new(); - for scenario in SCENARIOS { - let manifest = - read_json(scenario, "manifest.json").unwrap_or_else(|error| panic!("{error}")); - let expected = - read_json(scenario, "expected.json").unwrap_or_else(|error| panic!("{error}")); - for failure in schema_failures(scenario, &manifest, &expected) { - failures.push(format!("{scenario}: {failure}")); - } - } - assert!(failures.is_empty(), "{}", failures.join("\n")); -} - -#[test] -fn physical_evidence_is_synthetic_bounded_and_byte_exact() { - let mut failures = Vec::new(); - for scenario in SCENARIOS { - let manifest = - read_json(scenario, "manifest.json").unwrap_or_else(|error| panic!("{error}")); - for artifact in manifest["artifacts"] - .as_array() - .expect("manifest artifacts are an array") - { - let Some(relative_path) = artifact["relativePath"].as_str() else { - continue; - }; - let artifact_id = artifact["artifactId"].as_str().unwrap_or(""); - let path = corpus_root().join(scenario).join(relative_path); - let content = match fs::read_to_string(&path) { - Ok(content) => content, - Err(error) => { - failures.push(format!("{scenario}/{artifact_id}: {error}")); - continue; - } - }; - if artifact["bytesCopied"].as_u64() != Some(content.len() as u64) { - failures.push(format!( - "{scenario}/{artifact_id}: bytesCopied does not match physical bytes" - )); - } - if content.is_empty() - || !content.contains("SYNTHETIC") - || content.contains("C:\\") - || content.contains("\\\\") - || content.contains(".local") - || content.contains(".com") - { - failures.push(format!( - "{scenario}/{artifact_id}: evidence is empty or not safely synthetic" - )); - } - let byte_limit = artifact["collectionLimit"]["byteLimit"] - .as_u64() - .unwrap_or_default(); - if content.len() as u64 > byte_limit { - failures.push(format!( - "{scenario}/{artifact_id}: evidence exceeds collection cap" - )); - } - } - } - assert!(failures.is_empty(), "{}", failures.join("\n")); -} - -#[test] -fn request_transactions_are_exact_cited_ordered_and_layer_local() { - let mut failures = Vec::new(); - for scenario in SCENARIOS { - let manifest = - read_json(scenario, "manifest.json").unwrap_or_else(|error| panic!("{error}")); - let expected = - read_json(scenario, "expected.json").unwrap_or_else(|error| panic!("{error}")); - let records = normalized_records(scenario, &manifest); - let artifact_layers = manifest["artifacts"] - .as_array() - .expect("manifest artifacts are an array") - .iter() - .filter_map(|artifact| { - Some(( - artifact["artifactId"].as_str()?.to_owned(), - artifact["layer"].as_str()?.to_owned(), - )) - }) - .collect::>(); - - let transactions = expected["transactions"] - .as_array() - .expect("transactions are an array"); - let outcomes = transactions - .iter() - .map(|transaction| { - ( - transaction["state"].as_str().unwrap_or_default(), - transaction["classification"].as_str().unwrap_or_default(), - transaction["confidence"].as_str().unwrap_or_default(), - transaction["confidenceCeiling"] - .as_str() - .unwrap_or_default(), - ) - }) - .collect::>(); - if outcomes != expected_outcomes(scenario) { - failures.push(format!("{scenario}: outcome matrix changed")); - } - - for transaction in transactions { - let transaction_id = transaction["transactionId"].as_str().unwrap_or(""); - let layer = transaction["layer"].as_str().unwrap_or_default(); - let key = &transaction["key"]; - let mut prior_utc = i64::MIN; - let mut cited_terminal = false; - let mut cited_records = BTreeSet::new(); - for observation in transaction["observations"] - .as_array() - .expect("transaction observations are an array") - { - let observation_id = observation["observationId"].as_str().unwrap_or(""); - let phase = observation["phase"].as_str().unwrap_or_default(); - let disposition = observation["disposition"].as_str().unwrap_or_default(); - let terminal = observation["terminal"].as_bool().unwrap_or(false); - cited_terminal |= terminal; - for reference in observation["evidence"] - .as_array() - .expect("evidence is an array") - { - let artifact_id = reference["artifactId"].as_str().unwrap_or_default(); - let Some(start_line) = reference["startLine"] - .as_u64() - .and_then(|line| u32::try_from(line).ok()) - else { - failures.push(format!( - "{scenario}/{observation_id}: citation start line exceeds u32" - )); - continue; - }; - let Some(end_line) = reference["endLine"] - .as_u64() - .and_then(|line| u32::try_from(line).ok()) - else { - failures.push(format!( - "{scenario}/{observation_id}: citation end line exceeds u32" - )); - continue; - }; - let record_key = (artifact_id.to_owned(), start_line, end_line); - let Some(record) = records.get(&record_key) else { - failures.push(format!( - "{scenario}/{observation_id}: citation is not one logical CCM record" - )); - continue; - }; - if !cited_records.insert(record_key) - || artifact_layers.get(artifact_id).map(String::as_str) != Some(layer) - { - failures.push(format!( - "{scenario}/{transaction_id}: evidence is reused or cross-layer" - )); - } - 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 [ - ("RequestId", key["requestId"].as_str()), - ("OperationHandle", key["operationHandle"].as_str()), - ("EndpointId", key["endpointId"].as_str()), - ("Layer", Some(layer)), - ("ProfileId", key["extractionProfileId"].as_str()), - ] { - 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 record.timestamp.ordering_state - != SccmTimeOrderingState::NormalizedUtc - || record.timestamp.utc_millis.is_none() - || record - .timestamp - .utc_millis - .is_some_and(|utc| utc < prior_utc) - { - failures.push(format!( - "{scenario}/{transaction_id}: time is unusable or reversed" - )); - } - if let Some(utc) = record.timestamp.utc_millis { - prior_utc = utc; - } - } - Some("unusableInvalidOffset") => { - if record.timestamp.ordering_state - != SccmTimeOrderingState::OffsetInvalid - { - failures.push(format!( - "{scenario}/{transaction_id}: invalid offset became 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" - )); - } - } - - for failure in uncited_transaction_record_failures(&expected, &records) { - failures.push(format!("{scenario}: {failure}")); - } - - if scenario == "rotation-boundary" && !records.is_empty() { - failures.push( - "rotation-boundary: partial rotation fragments formed logical evidence".to_owned(), - ); - } - } - assert!(failures.is_empty(), "{}", failures.join("\n")); -} - -#[test] -fn privacy_iis_and_missing_source_controls_stay_conservative() { - let privacy_manifest = read_json("privacy-redaction", "manifest.json").unwrap(); - let privacy_expected = read_json("privacy-redaction", "expected.json").unwrap(); - let privacy_public = serde_json::to_string(&privacy_expected) - .expect("privacy expected output serializes") - .to_ascii_lowercase(); - for raw in [ - "synthetic.user@example.invalid", - "synthetic-raw-bearer-do-not-export", - "select * from sms_r_system", - "/adminservice/v1.0/device", - ] { - assert!( - !privacy_public.contains(raw), - "public output leaked synthetic sensitive shape {raw}" - ); - } - let privacy_raw = privacy_manifest["artifacts"] - .as_array() - .unwrap() - .iter() - .filter_map(|artifact| artifact["relativePath"].as_str()) - .map(|path| fs::read_to_string(corpus_root().join("privacy-redaction").join(path)).unwrap()) - .collect::() - .to_ascii_lowercase(); - assert!(privacy_raw.contains("synthetic.user@example.invalid")); - assert!(privacy_raw.contains("synthetic-raw-bearer-do-not-export")); - assert!(privacy_raw.contains("select * from sms_r_system")); - assert!(privacy_raw.contains("/adminservice/v1.0/device")); - - let privacy_transactions = privacy_expected["transactions"].as_array().unwrap(); - assert_eq!(privacy_transactions.len(), 2); - assert_eq!( - privacy_transactions[0]["key"]["requestId"], - privacy_transactions[1]["key"]["requestId"] - ); - assert_ne!( - privacy_transactions[0]["transactionId"], - privacy_transactions[1]["transactionId"] - ); - assert_ne!( - privacy_transactions[0]["layer"], - privacy_transactions[1]["layer"] - ); - - let iis_expected = read_json("iis-supplemental", "expected.json").unwrap(); - let iis_transaction_evidence = serde_json::to_string(&iis_expected["transactions"]).unwrap(); - assert!(!iis_transaction_evidence.contains("iis-supplemental-current")); - assert_eq!( - iis_expected["sourceLocalObservations"][0]["kind"], - "supplementalOnly" - ); - assert_eq!( - iis_expected["sourceLocalObservations"][0]["correlationEligible"], - false - ); - - let provider_timeout = read_json("provider-timeout", "expected.json").unwrap(); - assert_eq!( - provider_timeout["artifactRequests"][0]["logicalArtifactId"], - "server-provider" - ); - let incomplete = read_json("incomplete", "expected.json").unwrap(); - assert_eq!( - incomplete["artifactRequests"][0]["logicalArtifactId"], - "server-admin-service" - ); -} - -#[test] -fn noncaptured_malformed_and_blocked_deferred_coverage_are_not_outcomes() { - let coverage_cases = [ - ( - "admin-service-access-denied", - "accessDenied", - "adminService", - "server-admin-service", - ), - ( - "admin-service-parse-failed", - "parseFailed", - "adminService", - "server-admin-service", - ), - ( - "admin-service-skipped", - "skipped", - "adminService", - "server-admin-service", - ), - ( - "provider-source-absent", - "absent", - "provider", - "server-provider", - ), - ( - "provider-source-capped", - "capped", - "provider", - "server-provider", - ), - ( - "provider-source-unsupported", - "unsupported", - "provider", - "server-provider", - ), - ]; - - for (scenario, state, layer, request) in coverage_cases { - let expected = read_json(scenario, "expected.json").unwrap(); - assert_eq!(expected["coverage"][0]["state"], state, "{scenario}"); - assert_eq!(expected["coverage"][0]["layer"], layer, "{scenario}"); - assert_eq!( - expected["transactions"], - serde_json::json!([]), - "{scenario}" - ); - assert_eq!( - expected["artifactRequests"][0]["logicalArtifactId"], request, - "{scenario}" - ); - assert_eq!( - expected["crossSideCausalClaims"], - serde_json::json!([]), - "{scenario}" - ); - } - - let blocked = read_json("blocked-deferred", "expected.json").unwrap(); - let transaction = &blocked["transactions"][0]; - assert_eq!(transaction["state"], "incomplete"); - assert_eq!(transaction["classification"], "insufficientEvidence"); - assert_eq!(transaction["confidence"], "low"); - assert_eq!(transaction["terminalEvidence"], false); - assert_eq!(transaction["observations"][1]["disposition"], "pending"); - assert_eq!( - blocked["artifactRequests"][0]["logicalArtifactId"], - "server-admin-service" - ); -} - -#[test] -fn schema_and_identity_mutations_fail_closed() { - let manifest = read_json("provider-success", "manifest.json").unwrap(); - let expected = read_json("provider-success", "expected.json").unwrap(); - let iis_manifest = read_json("iis-supplemental", "manifest.json").unwrap(); - let iis_expected = read_json("iis-supplemental", "expected.json").unwrap(); - let timeout_manifest = read_json("provider-timeout", "manifest.json").unwrap(); - let timeout_expected = read_json("provider-timeout", "expected.json").unwrap(); - let mut accepted = Vec::new(); - - let mut extra_field = manifest.clone(); - extra_field["unexpected"] = Value::Bool(true); - if schema_failures("provider-success", &extra_field, &expected).is_empty() { - accepted.push("unknown manifest field"); - } - - let mut wrong_role = manifest.clone(); - wrong_role["artifacts"][0]["producerRole"] = Value::String("adminService".to_owned()); - if schema_failures("provider-success", &wrong_role, &expected).is_empty() { - accepted.push("wrong producer role"); - } - - let mut unknown_version = manifest.clone(); - unknown_version["artifacts"][0]["sourceVersion"] = Value::String("9.99.UNKNOWN".to_owned()); - if schema_failures("provider-success", &unknown_version, &expected).is_empty() { - accepted.push("unknown version retained selected profile"); - } - - let mut control_version = manifest.clone(); - control_version["artifacts"][0]["sourceVersion"] = - Value::String("5.00.TEST.0001\n9.99.UNKNOWN".to_owned()); - if schema_failures("provider-success", &control_version, &expected).is_empty() { - accepted.push("control-character source version retained selected profile"); - } - - let mut blank_artifact = manifest.clone(); - blank_artifact["artifacts"][0]["artifactId"] = Value::String(String::new()); - let mut blank_artifact_expected = expected.clone(); - blank_artifact_expected["coverage"][0]["artifactId"] = Value::String(String::new()); - for observation in blank_artifact_expected["transactions"][0]["observations"] - .as_array_mut() - .unwrap() - { - observation["evidence"][0]["artifactId"] = Value::String(String::new()); - } - if schema_failures( - "provider-success", - &blank_artifact, - &blank_artifact_expected, - ) - .is_empty() - { - accepted.push("blank artifact identity with internally rewritten references"); - } - - let mut unsafe_path = manifest.clone(); - unsafe_path["artifacts"][0]["relativePath"] = - Value::String("evidence/../outside/Smsprov.log".to_owned()); - if schema_failures("provider-success", &unsafe_path, &expected).is_empty() { - accepted.push("unsafe relative path"); - } - - let mut endpoint_only = expected.clone(); - endpoint_only["transactions"][0]["key"]["requestId"] = Value::String(String::new()); - if schema_failures("provider-success", &manifest, &endpoint_only).is_empty() { - accepted.push("endpoint-only key"); - } - - let mut time_only = expected.clone(); - time_only["transactions"][0]["key"]["requestId"] = Value::String("same-minute-only".to_owned()); - time_only["transactions"][0]["key"]["operationHandle"] = - Value::String("safe-operation-time-only".to_owned()); - time_only["transactions"][0]["transactionId"] = Value::String( - "provider:same-minute-only:safe-operation-time-only:provider-local".to_owned(), - ); - if schema_failures("provider-success", &manifest, &time_only).is_empty() { - accepted.push("time-like arbitrary key replaced profile fixture identity"); - } - - let mut non_array_evidence = expected.clone(); - non_array_evidence["transactions"][0]["observations"][0]["evidence"] = Value::Bool(false); - if schema_failures("provider-success", &manifest, &non_array_evidence).is_empty() { - accepted.push("non-array transaction evidence"); - } - - let mut duplicate_observation = expected.clone(); - let first = duplicate_observation["transactions"][0]["observations"][0].clone(); - duplicate_observation["transactions"][0]["observations"] - .as_array_mut() - .unwrap() - .push(first); - if schema_failures("provider-success", &manifest, &duplicate_observation).is_empty() { - accepted.push("duplicate transaction observation"); - } - - let mut arbitrary_observation = expected.clone(); - arbitrary_observation["transactions"][0]["observations"][0]["observationId"] = - Value::String("arbitrary-observation".to_owned()); - if schema_failures("provider-success", &manifest, &arbitrary_observation).is_empty() { - accepted.push("arbitrary transaction observation identity"); - } - - let mut incomplete_high = manifest.clone(); - incomplete_high["artifacts"][0]["rotation"]["fragmentComplete"] = Value::Bool(false); - if schema_failures("provider-success", &incomplete_high, &expected).is_empty() { - accepted.push("incomplete physical fragment retained high confidence"); - } - - let mut altered_outcome = expected.clone(); - altered_outcome["transactions"][0]["state"] = Value::String("unknown".to_owned()); - if schema_failures("provider-success", &manifest, &altered_outcome).is_empty() { - accepted.push("arbitrary transaction outcome"); - } - - let mut iis_as_primary = iis_expected.clone(); - iis_as_primary["transactions"][0]["observations"][0]["evidence"][0]["artifactId"] = - Value::String("iis-supplemental-current".to_owned()); - if schema_failures("iis-supplemental", &iis_manifest, &iis_as_primary).is_empty() { - accepted.push("supplemental IIS evidence became an Admin Service transaction"); - } - - let mut arbitrary_source_local = iis_expected.clone(); - arbitrary_source_local["sourceLocalObservations"][0]["observationId"] = - Value::String("arbitrary-source-local".to_owned()); - if schema_failures("iis-supplemental", &iis_manifest, &arbitrary_source_local).is_empty() { - accepted.push("arbitrary source-local observation identity"); - } - - let mut missing_request = timeout_expected.clone(); - missing_request["artifactRequests"] = Value::Array(Vec::new()); - if schema_failures("provider-timeout", &timeout_manifest, &missing_request).is_empty() { - accepted.push("required bounded follow-up request omitted"); - } - - let mut unsafe_summary = expected.clone(); - unsafe_summary["transactions"][0]["publicSummary"] = - Value::String("SELECT * FROM SMS_R_System for user@example.invalid".to_owned()); - if schema_failures("provider-success", &manifest, &unsafe_summary).is_empty() { - accepted.push("sensitive public summary"); - } - - let mut broad_request = expected.clone(); - broad_request["artifactRequests"] = serde_json::json!([{ - "logicalArtifactId": "server-provider", - "reason": "Collect all logs from the entire drive." - }]); - if schema_failures("provider-success", &manifest, &broad_request).is_empty() { - accepted.push("broad artifact request"); - } - - let mut cross_side_claim = expected.clone(); - cross_side_claim["crossSideCausalClaims"] = - serde_json::json!(["same-time client failure was caused"]); - if schema_failures("provider-success", &manifest, &cross_side_claim).is_empty() { - accepted.push("cross-side causal claim"); - } - - assert!(accepted.is_empty(), "accepted mutations: {accepted:#?}"); -} - -#[test] -fn review_privacy_topology_profile_and_citation_mutations_fail_closed() { - let manifest = read_json("provider-success", "manifest.json").unwrap(); - let expected = read_json("provider-success", "expected.json").unwrap(); - let privacy_manifest = read_json("privacy-redaction", "manifest.json").unwrap(); - let privacy_expected = read_json("privacy-redaction", "expected.json").unwrap(); - let iis_manifest = read_json("iis-supplemental", "manifest.json").unwrap(); - let iis_expected = read_json("iis-supplemental", "expected.json").unwrap(); - let timeout_manifest = read_json("provider-timeout", "manifest.json").unwrap(); - let timeout_expected = read_json("provider-timeout", "expected.json").unwrap(); - let mut accepted = Vec::new(); - - let mut noncanonical_known_version = manifest.clone(); - noncanonical_known_version["artifacts"][0]["sourceVersion"] = - Value::String("5.00.TEST.1".to_owned()); - if schema_failures("provider-success", &noncanonical_known_version, &expected).is_empty() { - accepted.push("noncanonical synthetic source version selected an exact profile"); - } - - let mut overlong_known_version = manifest.clone(); - overlong_known_version["artifacts"][0]["sourceVersion"] = - Value::String("5.00.TEST.00010".to_owned()); - if schema_failures("provider-success", &overlong_known_version, &expected).is_empty() { - accepted.push("overlong synthetic source version selected an exact profile"); - } - - let mut topology_host_mismatch = manifest.clone(); - topology_host_mismatch["artifacts"][0]["producerHostHandle"] = - Value::String("safe:server:different-host".to_owned()); - if schema_failures("provider-success", &topology_host_mismatch, &expected).is_empty() { - accepted.push("artifact producer host diverged from its endpoint host"); - } - - let mut identity_bearing_host = manifest.clone(); - identity_bearing_host["topology"]["endpoints"][0]["hostHandle"] = - Value::String("safe:server:synthetic.user@example.invalid".to_owned()); - identity_bearing_host["artifacts"][0]["producerHostHandle"] = - Value::String("safe:server:synthetic.user@example.invalid".to_owned()); - if schema_failures("provider-success", &identity_bearing_host, &expected).is_empty() { - accepted.push("identity-bearing host provenance"); - } - - let mut identity_bearing_source_path = manifest.clone(); - identity_bearing_source_path["artifacts"][0]["sanitizedSourcePath"] = - Value::String("SYNTHETIC://Users/Adam.Gell/LAB/Logs/Smsprov.log".to_owned()); - if schema_failures("provider-success", &identity_bearing_source_path, &expected).is_empty() { - accepted.push("identity-bearing sanitized source path"); - } - - let mut identity_bearing_fingerprint = manifest.clone(); - identity_bearing_fingerprint["artifacts"][0]["pathFingerprint"] = - Value::String("synthetic:synthetic.user@example.invalid".to_owned()); - if schema_failures("provider-success", &identity_bearing_fingerprint, &expected).is_empty() { - accepted.push("identity-bearing path fingerprint"); - } - - let mut basename_mismatch = manifest.clone(); - basename_mismatch["artifacts"][0]["sanitizedSourcePath"] = - Value::String("SYNTHETIC://configured-root/LAB/Logs/AdminService.log".to_owned()); - if schema_failures("provider-success", &basename_mismatch, &expected).is_empty() { - accepted.push("sanitized source path basename diverged from source identity"); - } - - let mut duplicate_sanitized_physical_identity = privacy_manifest.clone(); - duplicate_sanitized_physical_identity["artifacts"][1]["sanitizedSourcePath"] = - duplicate_sanitized_physical_identity["artifacts"][0]["sanitizedSourcePath"].clone(); - if schema_failures( - "privacy-redaction", - &duplicate_sanitized_physical_identity, - &privacy_expected, - ) - .is_empty() - { - accepted.push("duplicate sanitized physical identity hidden by distinct fingerprints"); - } - - let mut out_of_range_source_local_citation = iis_expected.clone(); - out_of_range_source_local_citation["sourceLocalObservations"][0]["evidence"][0]["startLine"] = - Value::from(9999_u64); - out_of_range_source_local_citation["sourceLocalObservations"][0]["evidence"][0]["endLine"] = - Value::from(9999_u64); - if schema_failures( - "iis-supplemental", - &iis_manifest, - &out_of_range_source_local_citation, - ) - .is_empty() - { - accepted.push("source-local citation points outside physical evidence"); - } - - let mut reversed_source_local_citation = iis_expected.clone(); - reversed_source_local_citation["sourceLocalObservations"][0]["evidence"][0]["startLine"] = - Value::from(2_u64); - reversed_source_local_citation["sourceLocalObservations"][0]["evidence"][0]["endLine"] = - Value::from(1_u64); - if schema_failures( - "iis-supplemental", - &iis_manifest, - &reversed_source_local_citation, - ) - .is_empty() - { - accepted.push("source-local citation has a reversed line range"); - } - - let mut private_source_local_reason = privacy_expected.clone(); - private_source_local_reason["sourceLocalObservations"][0]["reason"] = - Value::String("Caller synthetic.user@example.invalid used a raw token".to_owned()); - if schema_failures( - "privacy-redaction", - &privacy_manifest, - &private_source_local_reason, - ) - .is_empty() - { - accepted.push("source-local public reason leaks caller identity"); - } - - let mut private_request_reason = timeout_expected.clone(); - private_request_reason["artifactRequests"][0]["reason"] = - Value::String("Capture caller synthetic.user@example.invalid bearer token".to_owned()); - if schema_failures( - "provider-timeout", - &timeout_manifest, - &private_request_reason, - ) - .is_empty() - { - accepted.push("artifact request public reason leaks caller and token material"); - } - - let mut extra_unobserved_endpoint = manifest.clone(); - extra_unobserved_endpoint["topology"]["endpoints"] - .as_array_mut() - .unwrap() - .insert( - 0, - serde_json::json!({ - "endpointId": "aaa-unobserved", - "layer": "provider", - "hostHandle": "safe:server:unused", - "producerRole": "provider" - }), - ); - if schema_failures("provider-success", &extra_unobserved_endpoint, &expected).is_empty() { - accepted.push("unobserved extra endpoint entered exact topology"); - } - - assert!(accepted.is_empty(), "accepted mutations: {accepted:#?}"); -} - -#[test] -fn overflowing_transaction_citation_lines_fail_closed() { - let manifest = read_json("provider-success", "manifest.json").unwrap(); - let mut expected = read_json("provider-success", "expected.json").unwrap(); - let wrapped_line = u64::from(u32::MAX) + 2; - expected["transactions"][0]["observations"][0]["evidence"][0]["startLine"] = - Value::from(wrapped_line); - expected["transactions"][0]["observations"][0]["evidence"][0]["endLine"] = - Value::from(wrapped_line); - - assert!( - !schema_failures("provider-success", &manifest, &expected).is_empty(), - "u64 citation lines that wrap to a different u32 logical record were accepted" - ); -} - -#[test] -fn exported_manifest_and_expected_strings_fail_closed_on_private_shapes() { - let manifest = read_json("provider-success", "manifest.json").unwrap(); - let expected = read_json("provider-success", "expected.json").unwrap(); - let mut accepted = Vec::new(); - - let mut private_manifest = manifest.clone(); - private_manifest["artifacts"][0]["rotation"]["lineageId"] = - Value::String("synthetic.user@example.invalid".to_owned()); - if schema_failures("provider-success", &private_manifest, &expected).is_empty() { - accepted.push("manifest rotation lineage containing an email identity"); - } - - let mut private_expected = expected.clone(); - private_expected["transactions"][0]["publicSummary"] = Value::String( - r"CONTOSO\alice issued DELETE FROM SMS_R_System with ghp_abcdefghijklmnopqrstuvwxyz0123456789" - .to_owned(), - ); - if schema_failures("provider-success", &manifest, &private_expected).is_empty() { - accepted.push("public summary containing identity, query, and token shapes"); - } - - assert!(accepted.is_empty(), "accepted mutations: {accepted:#?}"); -} - -#[test] -fn split_rotation_members_with_divergent_lineage_fail_closed() { - let mut manifest = read_json("rotation-boundary", "manifest.json").unwrap(); - let expected = read_json("rotation-boundary", "expected.json").unwrap(); - manifest["artifacts"][1]["rotation"]["lineageId"] = - Value::String("different-lineage".to_owned()); - - assert!( - !schema_failures("rotation-boundary", &manifest, &expected).is_empty(), - "one source-local split rotation accepted divergent lineage provenance" - ); -} - -#[test] -fn incomplete_invalid_offset_transaction_cannot_claim_high_ceiling() { - let manifest = read_json("provider-timeout", "manifest.json").unwrap(); - let mut expected = read_json("provider-timeout", "expected.json").unwrap(); - expected["transactions"][0]["confidenceCeiling"] = Value::String("high".to_owned()); - - assert!( - !schema_failures("provider-timeout", &manifest, &expected).is_empty(), - "incomplete invalid-offset evidence accepted a high confidence ceiling" - ); -} - -#[test] -fn generic_artifact_request_reason_fails_closed() { - let manifest = read_json("provider-timeout", "manifest.json").unwrap(); - let mut expected = read_json("provider-timeout", "expected.json").unwrap(); - expected["artifactRequests"][0]["reason"] = Value::String("Collect logs.".to_owned()); - - assert!( - !schema_failures("provider-timeout", &manifest, &expected).is_empty(), - "generic request prose lost the exact unresolved request and terminal basis" - ); -} - -#[test] -fn later_uncited_same_key_terminal_failure_invalidates_high_success() { - let manifest = read_json("provider-success", "manifest.json").unwrap(); - let expected = read_json("provider-success", "expected.json").unwrap(); - let mut records = normalized_records("provider-success", &manifest); - let artifact = SccmArtifact { - artifact_id: "provider-success-current".to_owned(), - display_name: "Smsprov.log".to_owned(), - original_path: None, - host: Some("safe:server:lab-provider-01".to_owned()), - role: SccmRole::Provider, - configmgr_version: Some("5.00.TEST.0001".to_owned()), - collected_at_utc: Some("2026-07-30T21:00:00Z".to_owned()), - rotation: SccmRotation::Current, - coverage: SccmCoverageState::Captured, - encoding: Some("utf-8".to_owned()), - }; - let raw = ""; - let mut later_failure = normalize_ccm_artifact(artifact, raw) - .into_iter() - .next() - .expect("adversarial terminal record normalizes"); - later_failure.evidence_id = "provider-success-current:6-6".to_owned(); - later_failure.reference.entry_id = "provider-success-current:6-6".to_owned(); - later_failure.reference.line_start = Some(6); - later_failure.reference.line_end = Some(6); - records.insert(("provider-success-current".to_owned(), 6, 6), later_failure); - - assert!( - !schema_failures_with_records("provider-success", &manifest, &expected, &records) - .is_empty(), - "an uncited later terminal failure for the exact transaction key retained high success" - ); -} - -#[test] -fn terminal_marker_cannot_move_from_record_outcome_to_receive() { - let manifest = read_json("provider-success", "manifest.json").unwrap(); - let mut expected = read_json("provider-success", "expected.json").unwrap(); - expected["transactions"][0]["observations"][0]["terminal"] = Value::Bool(true); - expected["transactions"][0]["observations"][4]["terminal"] = Value::Bool(false); - - assert!( - !schema_failures("provider-success", &manifest, &expected).is_empty(), - "a receive marker satisfied terminal evidence after recordOutcome became nonterminal" - ); -} - -#[test] -fn applied_collection_limit_cannot_remain_captured_or_high() { - let mut manifest = read_json("provider-success", "manifest.json").unwrap(); - let expected = read_json("provider-success", "expected.json").unwrap(); - manifest["artifacts"][0]["collectionLimit"]["limitApplied"] = Value::Bool(true); - - assert!( - !schema_failures("provider-success", &manifest, &expected).is_empty(), - "limitApplied=true retained captured coverage and high-confidence success" - ); -} - -#[test] -fn relative_path_must_match_declared_source_rotation_and_basename() { - let mut manifest = read_json("rotation-boundary", "manifest.json").unwrap(); - let expected = read_json("rotation-boundary", "expected.json").unwrap(); - let current_path = manifest["artifacts"][0]["relativePath"].clone(); - let current_bytes = manifest["artifacts"][0]["bytesCopied"].clone(); - manifest["artifacts"][0]["relativePath"] = manifest["artifacts"][1]["relativePath"].clone(); - manifest["artifacts"][0]["bytesCopied"] = manifest["artifacts"][1]["bytesCopied"].clone(); - manifest["artifacts"][1]["relativePath"] = current_path; - manifest["artifacts"][1]["bytesCopied"] = current_bytes; - - assert!( - !schema_failures("rotation-boundary", &manifest, &expected).is_empty(), - "current and lo_ artifacts accepted each other's physical paths" - ); -} - -#[test] -fn unknown_source_version_requires_canonical_public_grammar() { - let manifest = read_json("rotation-boundary", "manifest.json").unwrap(); - let expected = read_json("rotation-boundary", "expected.json").unwrap(); - - for unsafe_version in ["SyntheticCaller", "1.2.SYNTHETICCALLER"] { - let mut mutated = manifest.clone(); - for artifact in mutated["artifacts"].as_array_mut().unwrap() { - artifact["sourceVersion"] = Value::String(unsafe_version.to_owned()); - } - assert!( - !schema_failures("rotation-boundary", &mutated, &expected).is_empty(), - "caller-shaped text {unsafe_version} was exported as unknown source-version provenance" - ); - } -} - -#[test] -fn exact_rotation_topology_rejects_an_empty_endpoint() { - let mut manifest = read_json("rotation-boundary", "manifest.json").unwrap(); - let expected = read_json("rotation-boundary", "expected.json").unwrap(); - manifest["topology"]["endpoints"][0]["endpointId"] = Value::String(String::new()); - for artifact in manifest["artifacts"].as_array_mut().unwrap() { - artifact["endpointId"] = Value::String(String::new()); - } - - assert!( - !schema_failures("rotation-boundary", &manifest, &expected).is_empty(), - "an empty endpoint retained exact topology when no transaction was emitted" - ); -} - -#[test] -fn provider_sensitive_fields_never_enter_public_or_candidate_projections() { - const REDACTED: &str = "[redacted:sccm-public-message-v1]"; - let prefix = "[sccm-public-message-v1] "; - let core = "Phase=receive; Disposition=succeeded; Terminal=false; RequestId=11111111-1111-1111-1111-111111111111; OperationHandle=safe-operation-read-device; EndpointId=provider-local; Layer=provider; ProfileId=provider-server-5.00.test-v1"; - let raw_fields = [ - ( - "CallerHandle=opaque-private-caller", - "opaque-private-caller", - ), - ( - "CallerHandle=private-caller-middle", - "private-caller-middle", - ), - ("CallerHandle=private-caller-tail", "private-caller-tail"), - ( - "QueryHandle=/AdminService/v1.0/device", - "/AdminService/v1.0/device", - ), - ( - "QueryHandle=SELECT * FROM SMS_R_System", - "SELECT * FROM SMS_R_System", - ), - ("QueryHandle=opaque-private-query", "opaque-private-query"), - ( - "Authorization=Bearer private-auth-start", - "private-auth-start", - ), - ( - "Authorization=Custom private-auth-middle", - "private-auth-middle", - ), - ("Authorization=Basic private-auth-tail", "private-auth-tail"), - ]; - - for (index, (raw_field, sensitive)) in raw_fields.into_iter().enumerate() { - let message = match index % 3 { - 0 => format!("{prefix}SYNTHETIC FIXTURE; {raw_field}; {core}"), - 1 => format!( - "{prefix}SYNTHETIC FIXTURE; Phase=receive; {raw_field}; Disposition=succeeded; Terminal=false; RequestId=11111111-1111-1111-1111-111111111111; OperationHandle=safe-operation-read-device; EndpointId=provider-local; Layer=provider; ProfileId=provider-server-5.00.test-v1" - ), - _ => format!("{prefix}SYNTHETIC FIXTURE; {core}; {raw_field}"), - }; - let error = parse_fixture_fields(&message) - .expect_err("a raw sensitive fixture field must fail closed"); - assert!( - !error.contains(sensitive), - "candidate error leaked {sensitive}" - ); - } - - for field in ["CallerHandle", "QueryHandle", "Authorization"] { - let message = format!("{prefix}SYNTHETIC FIXTURE; {core}; {field}={REDACTED}"); - let fields = parse_fixture_fields(&message) - .unwrap_or_else(|error| panic!("{field} redaction marker was rejected: {error}")); - assert!( - !fields.contains_key(field), - "{field} entered exact facts after redaction" - ); - } - - let manifest = read_json("privacy-redaction", "manifest.json").unwrap(); - let records = normalized_records("privacy-redaction", &manifest); - let public_json = serde_json::to_string(&records.values().collect::>()).unwrap(); - for sensitive in [ - "synthetic.user@example.invalid", - "SYNTHETIC-RAW-BEARER-DO-NOT-EXPORT", - "SELECT * FROM SMS_R_System", - "/AdminService/v1.0/device", - ] { - assert!( - !public_json.contains(sensitive), - "public evidence leaked {sensitive}" - ); - } - for record in records.values() { - let fields = parse_fixture_fields(&record.message) - .unwrap_or_else(|error| panic!("projected fixture field is invalid: {error}")); - for field in ["CallerHandle", "QueryHandle", "Authorization"] { - assert!( - !fields.contains_key(field), - "{field} entered an exact-key correlation candidate" - ); - } - } -} - -#[test] -fn nonterminal_disposition_and_recovery_vocabulary_is_closed() { - let manifest = read_json("provider-success", "manifest.json").unwrap(); - let expected = read_json("provider-success", "expected.json").unwrap(); - let records = normalized_records("provider-success", &manifest); - let mut accepted = Vec::new(); - - for disposition in ["manufacturedRecovery", "failed", "retryableFailure"] { - let mut paired_expected = expected.clone(); - let observation = &mut paired_expected["transactions"][0]["observations"][2]; - observation["disposition"] = Value::String(disposition.to_owned()); - let artifact_id = observation["evidence"][0]["artifactId"] - .as_str() - .unwrap() - .to_owned(); - let start_line = - u32::try_from(observation["evidence"][0]["startLine"].as_u64().unwrap()).unwrap(); - let end_line = - u32::try_from(observation["evidence"][0]["endLine"].as_u64().unwrap()).unwrap(); - let mut paired_records = records.clone(); - let record = paired_records - .get_mut(&(artifact_id, start_line, end_line)) - .expect("paired evidence record exists"); - record.message = record.message.replacen( - "Disposition=succeeded", - &format!("Disposition={disposition}"), - 1, - ); - - if schema_failures_with_records( - "provider-success", - &manifest, - &paired_expected, - &paired_records, - ) - .is_empty() - { - accepted.push(disposition); - } - } - - assert!( - accepted.is_empty(), - "paired high-success mutations accepted nonterminal dispositions: {accepted:#?}" - ); -} - -#[test] -fn provider_retry_requires_an_explicit_retryable_failure_then_recovery() { - let manifest = read_json("provider-retry", "manifest.json").unwrap(); - let mut expected = read_json("provider-retry", "expected.json").unwrap(); - expected["transactions"][0]["observations"][2]["disposition"] = - Value::String("succeeded".to_owned()); - - assert!( - !schema_failures("provider-retry", &manifest, &expected).is_empty(), - "a nominal success sequence replaced the explicit retryable failure" - ); -} - -#[test] -fn high_success_requires_every_layer_phase_in_order() { - let cases = [ - ( - "provider-success", - 1_usize, - "authenticateOrAuthorize", - "receive", - ), - ( - "admin-service-success", - 2_usize, - "route", - "authenticateOrAuthorize", - ), - ]; - let mut accepted = Vec::new(); - - for (scenario, observation_index, required_phase, replacement_phase) in cases { - let manifest = read_json(scenario, "manifest.json").unwrap(); - let mut expected = read_json(scenario, "expected.json").unwrap(); - let mut records = normalized_records(scenario, &manifest); - assert!( - schema_failures_with_records(scenario, &manifest, &expected, &records).is_empty(), - "{scenario}: baseline contract is invalid" - ); - - let observation = &mut expected["transactions"][0]["observations"][observation_index]; - observation["phase"] = Value::String(replacement_phase.to_owned()); - let artifact_id = observation["evidence"][0]["artifactId"] - .as_str() - .unwrap() - .to_owned(); - let start_line = - u32::try_from(observation["evidence"][0]["startLine"].as_u64().unwrap()).unwrap(); - let end_line = - u32::try_from(observation["evidence"][0]["endLine"].as_u64().unwrap()).unwrap(); - let record = records - .get_mut(&(artifact_id, start_line, end_line)) - .expect("paired logical record exists"); - record.message = record.message.replacen( - &format!("Phase={required_phase}"), - &format!("Phase={replacement_phase}"), - 1, - ); - - if schema_failures_with_records(scenario, &manifest, &expected, &records).is_empty() { - accepted.push(scenario); - } - } - - assert!( - accepted.is_empty(), - "paired high-success records omitted required phases: {accepted:#?}" - ); -} - -#[test] -fn phase_specific_failures_require_the_named_failed_phase() { - let cases = [ - ( - "provider-authz-denied", - 1_usize, - "authenticateOrAuthorize", - "receive", - "provider authorization", - ), - ( - "admin-service-auth-failure", - 1_usize, - "authenticateOrAuthorize", - "receive", - "Admin Service authentication", - ), - ( - "provider-query-failure", - 2_usize, - "executeProviderOperation", - "authenticateOrAuthorize", - "Provider operation", - ), - ( - "admin-service-backend-failure", - 3_usize, - "executeBackendOperation", - "route", - "Admin Service backend operation", - ), - ]; - let mut accepted = Vec::new(); - - for (scenario, observation_index, failed_phase, replacement_phase, label) in cases { - for (mutation, replacement_phase) in [ - ("without its named phase", Some(replacement_phase)), - ("with a successful named phase", None), - ] { - let manifest = read_json(scenario, "manifest.json").unwrap(); - let mut expected = read_json(scenario, "expected.json").unwrap(); - let mut records = normalized_records(scenario, &manifest); - assert!( - schema_failures_with_records(scenario, &manifest, &expected, &records).is_empty(), - "{scenario}: baseline contract is invalid" - ); - - let observation = &mut expected["transactions"][0]["observations"][observation_index]; - if let Some(replacement_phase) = replacement_phase { - observation["phase"] = Value::String(replacement_phase.to_owned()); - } - observation["disposition"] = Value::String("succeeded".to_owned()); - let artifact_id = observation["evidence"][0]["artifactId"] - .as_str() - .unwrap() - .to_owned(); - let start_line = - u32::try_from(observation["evidence"][0]["startLine"].as_u64().unwrap()).unwrap(); - let end_line = - u32::try_from(observation["evidence"][0]["endLine"].as_u64().unwrap()).unwrap(); - let record = records - .get_mut(&(artifact_id, start_line, end_line)) - .expect("paired logical record exists"); - if let Some(replacement_phase) = replacement_phase { - record.message = record.message.replacen( - &format!("Phase={failed_phase}"), - &format!("Phase={replacement_phase}"), - 1, - ); - } - record.message = - record - .message - .replacen("Disposition=failed", "Disposition=succeeded", 1); - - if schema_failures_with_records(scenario, &manifest, &expected, &records).is_empty() { - accepted.push(format!("{label} {mutation}")); - } - } - } - - assert!( - accepted.is_empty(), - "phase-specific failure summaries survived without their named failed phase: {accepted:#?}" - ); -} - -#[test] -fn provider_retry_recovery_is_bound_to_the_failed_phase() { - let scenario = "provider-retry"; - let manifest = read_json(scenario, "manifest.json").unwrap(); - let mut expected = read_json(scenario, "expected.json").unwrap(); - let mut records = normalized_records(scenario, &manifest); - assert!( - schema_failures_with_records(scenario, &manifest, &expected, &records).is_empty(), - "provider-retry baseline contract is invalid" - ); - - let observation = &mut expected["transactions"][0]["observations"][0]; - observation["disposition"] = Value::String("retryableFailure".to_owned()); - let artifact_id = observation["evidence"][0]["artifactId"] - .as_str() - .unwrap() - .to_owned(); - let start_line = - u32::try_from(observation["evidence"][0]["startLine"].as_u64().unwrap()).unwrap(); - let end_line = u32::try_from(observation["evidence"][0]["endLine"].as_u64().unwrap()).unwrap(); - let record = records - .get_mut(&(artifact_id, start_line, end_line)) - .expect("paired retry logical record exists"); - record.message = - record - .message - .replacen("Disposition=succeeded", "Disposition=retryableFailure", 1); - - assert!( - !schema_failures_with_records(scenario, &manifest, &expected, &records).is_empty(), - "an unrecovered receive-phase retryable failure retained terminal high success" - ); -} - -#[test] -fn rejected_fixture_segment_diagnostic_omits_raw_value() { - let sensitive = "private-credential-value"; - let messages = [ - format!( - "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=receive; AuthorizationHeader {sensitive}" - ), - format!( - "[sccm-public-message-v1] SYNTHETIC FIXTURE; Phase=receive; Bearer {sensitive}=1" - ), - ]; - - for message in messages { - let error = - parse_fixture_fields(&message).expect_err("malformed fixture segment is rejected"); - assert!( - !error.contains(sensitive), - "rejected fixture diagnostic echoed a raw private value: {error}" - ); - } -} - -#[test] -fn provider_retry_scenario_is_explicit() { - read_json("provider-retry", "manifest.json").expect("provider retry manifest exists"); - read_json("provider-retry", "expected.json").expect("provider retry expectation exists"); -} - -#[test] -fn contradictory_evidence_scenario_is_explicit() { - read_json("contradictory-evidence", "manifest.json") - .expect("contradictory evidence manifest exists"); - read_json("contradictory-evidence", "expected.json") - .expect("contradictory evidence expectation exists"); } diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index f6914c58f..78fa136e7 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -7011,7 +7011,7 @@ fn catalog_requires_exact_producer_roles_for_server_workflow_sources() { ), ( "AdminService.log", - SccmRole::Provider, + SccmRole::AdminService, SccmArtifactFamily::AdminService, ), ]; @@ -7586,7 +7586,7 @@ fn expected_catalog_tuples() -> Vec { ), ( "AdminService.log", - SccmRole::Provider, + SccmRole::AdminService, "adminService", SccmArtifactFamily::AdminService, true, From 7c95922e8a558b65105e89d8d663830d26bc6bb2 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 03:09:43 -0400 Subject: [PATCH 406/422] fix(sccm): harden provider admin review contracts --- .../sccm/client/authority_contract_tests.rs | 104 +++- crates/cmtraceopen-parser/src/sccm/keys.rs | 36 +- crates/cmtraceopen-parser/src/sccm/models.rs | 4 + .../src/sccm/server/windows/intake.rs | 9 +- .../windows/provider_and_admin_service.rs | 545 ++++++++++++++---- .../admin-service-access-denied/expected.json | 93 ++- .../admin-service-auth-failure/expected.json | 173 +++++- .../expected.json | 213 ++++++- .../admin-service-parse-failed/expected.json | 93 ++- .../admin-service-skipped/expected.json | 93 ++- .../admin-service-success/expected.json | 161 +++++- .../blocked-deferred/expected.json | 198 ++++++- .../contradictory-evidence/expected.json | 238 +++++++- .../iis-supplemental/expected.json | 179 +++++- .../incomplete/expected.json | 158 ++++- .../privacy-redaction/expected.json | 311 +++++++++- .../provider-authz-denied/expected.json | 173 +++++- .../provider-query-failure/expected.json | 193 ++++++- .../provider-retry/expected.json | 225 +++++++- .../provider-source-absent/expected.json | 93 ++- .../provider-source-capped/expected.json | 102 +++- .../provider-source-unsupported/expected.json | 93 ++- .../provider-success/expected.json | 147 ++++- .../provider-timeout/expected.json | 198 ++++++- .../rotation-boundary/expected.json | 154 ++++- .../sccm_server_provider_and_admin_service.rs | 411 ++++++------- ...ider_and_admin_service_fixture_contract.rs | 387 +++++++++++-- .../tests/sccm_spine_contract.rs | 10 + 28 files changed, 4120 insertions(+), 674 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs index c2fccd71a..eaf9ad355 100644 --- a/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs +++ b/crates/cmtraceopen-parser/src/sccm/client/authority_contract_tests.rs @@ -9,9 +9,12 @@ use super::{ SccmClientIntakeCaptureGap, }; use crate::sccm::{ - extract_keys, SccmArtifact, SccmArtifactFamily, SccmCorrelationKeyKind, SccmCoverageState, - SccmExtractionGapKind, SccmExtractionProfile, SccmExtractionProfileMaturity, SccmKeyConfidence, - SccmRole, SccmRotation, SCCM_EXPERIMENTAL_KEY_PROFILE_ID, SCCM_POLICY_KEY_PROFILE_ID, + 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 { @@ -805,6 +808,101 @@ fn caller_constructed_stable_policy_profile_does_not_mint_exact_keys() { .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"); diff --git a/crates/cmtraceopen-parser/src/sccm/keys.rs b/crates/cmtraceopen-parser/src/sccm/keys.rs index 261520acc..ad9c438da 100644 --- a/crates/cmtraceopen-parser/src/sccm/keys.rs +++ b/crates/cmtraceopen-parser/src/sccm/keys.rs @@ -16,9 +16,12 @@ pub const SCCM_EXPERIMENTAL_KEY_PROFILE_ID: &str = "sccm-keys-5.00.9128-experime /// 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 { @@ -78,6 +81,22 @@ impl SccmExtractionProfile { 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) { @@ -354,10 +373,23 @@ fn is_builtin_stable_policy(profile: &SccmExtractionProfile) -> bool { } fn is_builtin_experimental(profile: &SccmExtractionProfile) -> bool { - is_builtin_experimental_core(profile) + (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() + && 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 { diff --git a/crates/cmtraceopen-parser/src/sccm/models.rs b/crates/cmtraceopen-parser/src/sccm/models.rs index d85c4833f..0e427b0ce 100644 --- a/crates/cmtraceopen-parser/src/sccm/models.rs +++ b/crates/cmtraceopen-parser/src/sccm/models.rs @@ -112,6 +112,8 @@ impl<'de> Deserialize<'de> for SccmRole { pub enum SccmFindingClass { Symptom, ConfirmedFailure, + Recovered, + ContradictoryEvidence, BlockedOrDeferred, LikelyContributor, InsufficientEvidence, @@ -122,6 +124,8 @@ impl SccmFindingClass { match self { Self::Symptom => "symptom", Self::ConfirmedFailure => "confirmedFailure", + Self::Recovered => "recovered", + Self::ContradictoryEvidence => "contradictoryEvidence", Self::BlockedOrDeferred => "blockedOrDeferred", Self::LikelyContributor => "likelyContributor", Self::InsufficientEvidence => "insufficientEvidence", diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 684a35718..455313fa7 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -1390,7 +1390,13 @@ fn normalize_artifact( }) { return Err(SccmServerIntakeError::InvalidArtifact); } - if parse_errors > 0 && artifact.fragment_complete != Some(false) { + if parse_errors > 0 + && (artifact.fragment_complete != Some(false) + || matches!( + family, + SccmArtifactFamily::Provider | SccmArtifactFamily::AdminService + )) + { state = SccmCoverageState::ParseFailed; } } else { @@ -3008,6 +3014,7 @@ fn safe_optional_handle(value: Option<&str>, synthetic_fixture: bool, domain: &s | "synthetic:host:site-03" | "synthetic:host:wsus-01" | "synthetic:host:provider-01" + | "synthetic:host:provider-02" | "synthetic:host:admin-service-01" ), "subject" => { 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 index e0ba59a0a..95f447b83 100644 --- 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 @@ -3,9 +3,12 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::Serialize; use sha2::{Digest, Sha256}; +use crate::models::log_entry::Severity; use crate::sccm::{ - SccmArtifactFamily, SccmArtifactRequest, SccmConfidence, SccmCoverageState, SccmEvidence, - SccmEvidenceRef, SccmFindingClass, SccmKeyConfidence, SccmRole, SccmTimeOrderingState, + extract_keys, SccmArtifactFamily, SccmArtifactRequest, SccmConfidence, SccmCorrelationKeyKind, + SccmCoverageState, SccmEvidence, SccmEvidenceRef, SccmExtractionProfile, SccmFinding, + SccmFindingBuilder, SccmFindingClass, SccmFindingCoverageGap, SccmKeyConfidence, SccmPhase, + SccmRole, SccmTerminalEvidence, SccmTimeOrderingState, }; use super::{SccmServerArtifactAssessment, SccmServerIntakeAssessment}; @@ -14,8 +17,6 @@ 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"; -const PROVIDER_PROFILE: &str = "provider-server-5.00.test-v1"; -const ADMIN_PROFILE: &str = "admin-service-server-5.00.test-v1"; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[serde(rename_all = "camelCase")] @@ -39,13 +40,6 @@ impl ProviderAdminServiceLayer { } } - fn profile_id(self) -> &'static str { - match self { - Self::Provider => PROVIDER_PROFILE, - Self::AdminService => ADMIN_PROFILE, - } - } - fn endpoint_token(self) -> &'static str { match self { Self::Provider => "provider-local", @@ -107,7 +101,9 @@ pub enum ProviderAdminServiceDisposition { #[serde(rename_all = "camelCase")] pub enum ProviderAdminServiceState { Succeeded, + Recovered, Failed, + Contradictory, BlockedOrDeferred, Incomplete, } @@ -116,7 +112,9 @@ pub enum ProviderAdminServiceState { #[serde(rename_all = "camelCase")] pub enum ProviderAdminServiceClassification { Success, + Recovered, ConfirmedFailure, + ContradictoryEvidence, BlockedOrDeferred, InsufficientEvidence, } @@ -125,7 +123,9 @@ 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), } @@ -164,8 +164,7 @@ pub enum ProviderAdminServiceSupportState { pub struct ProviderAdminServiceProfile { pub layer: ProviderAdminServiceLayer, pub selection_state: ProviderAdminServiceProfileSelection, - pub profile_id: &'static str, - pub source_version: &'static str, + pub extraction_profile: SccmExtractionProfile, pub limitation: &'static str, } @@ -175,7 +174,9 @@ pub struct ProviderAdminServiceCoverage { pub artifact_id: String, pub source_id: String, pub producer_role: SccmRole, - pub endpoint_handle: Option, + pub producer_host_handle: Option, + pub workflow_subject_handle: Option, + pub source_version: Option, pub state: SccmCoverageState, } @@ -187,7 +188,31 @@ pub struct ProviderAdminServiceKey { pub endpoint_handle: String, pub producer_host_handle: String, pub confidence: SccmKeyConfidence, - pub extraction_profile_id: &'static str, + 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)] @@ -218,7 +243,7 @@ pub struct ProviderAdminServiceTransaction { pub terminal_evidence: bool, pub last_successful_phase: Option, pub coverage_gap_artifact_ids: Vec, - pub next_artifact_request: Option, + pub next_artifact_requests: Vec, pub public_summary: String, pub observations: Vec, } @@ -248,8 +273,9 @@ pub struct ProviderAdminServiceAnalysis { pub profiles: Vec, pub coverage: Vec, pub transactions: Vec, + pub findings: Vec, pub source_local_observations: Vec, - pub artifact_requests: Vec, + pub artifact_requests: Vec, pub cross_side_causal_claims: Vec, } @@ -260,7 +286,6 @@ struct FactKey { operation: String, endpoint_handle: String, host_handle: String, - profile_id: String, } #[derive(Debug, Clone)] @@ -271,6 +296,12 @@ struct Fact { terminal: bool, evidence: SccmEvidenceRef, utc_millis: Option, + extraction_profile: SccmExtractionProfile, +} + +struct ReducedTransaction { + transaction: ProviderAdminServiceTransaction, + finding: Option, } enum ParsedFact { @@ -301,7 +332,9 @@ pub fn analyze_provider_admin_service( artifact_id: artifact.artifact_id.clone(), source_id: artifact.source_id.clone(), producer_role: artifact.producer_role.clone(), - endpoint_handle: artifact.workflow_subject_handle.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::>(); @@ -334,13 +367,27 @@ pub fn analyze_provider_admin_service( } } - let mut transactions = facts + let mut reduced = facts .into_iter() .filter_map(|(key, group)| { reduce_transaction(key.clone(), group, poisoned.contains(&key), &scoped) }) .collect::>(); - transactions.sort_by(|left, right| left.transaction_id.cmp(&right.transaction_id)); + 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)); @@ -348,19 +395,26 @@ pub fn analyze_provider_admin_service( let mut artifact_requests = global_artifact_requests(&scoped); for request in transactions .iter() - .filter_map(|transaction| transaction.next_artifact_request.clone()) + .flat_map(|transaction| transaction.next_artifact_requests.clone()) { - if !artifact_requests.iter().any(|existing| { - existing.logical_id == request.logical_id && existing.role == request.role - }) { + if !artifact_requests.contains(&request) { artifact_requests.push(request); } } artifact_requests.sort_by(|left, right| { - (left.logical_id.as_str(), role_name(&left.role)) - .cmp(&(right.logical_id.as_str(), role_name(&right.role))) + ( + 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(), + )) }); - artifact_requests.truncate(1); ProviderAdminServiceAnalysis { workflow: "providerAndAdminService", @@ -368,6 +422,7 @@ pub fn analyze_provider_admin_service( profiles: selected_profiles(&scoped), coverage, transactions, + findings, source_local_observations, artifact_requests, cross_side_causal_claims: Vec::new(), @@ -381,6 +436,7 @@ fn empty_analysis() -> ProviderAdminServiceAnalysis { 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(), @@ -400,24 +456,37 @@ fn selected_profiles( .iter() .any(|artifact| artifact.source_id == layer.source_id()) }) - .map(|layer| ProviderAdminServiceProfile { - layer, - selection_state: if artifacts.iter().any(|artifact| { + .filter_map(|layer| { + let selected = artifacts.iter().any(|artifact| { artifact.source_id == layer.source_id() && artifact.source_version.as_deref() == Some(SYNTHETIC_VERSION) - }) { - ProviderAdminServiceProfileSelection::SelectedSynthetic - } else { - ProviderAdminServiceProfileSelection::UnknownVersion - }, - profile_id: layer.profile_id(), - source_version: SYNTHETIC_VERSION, - limitation: - "Synthetic fixtures only; no reviewed real SCCM version or Windows lab validation.", + }); + 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), @@ -467,8 +536,12 @@ fn parse_fact( 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() != layer.profile_id() + || fields.get("ProfileId")?.as_str() != extraction_profile.profile_id || fields.get("EndpointId")?.as_str() != layer.endpoint_token() { return None; @@ -478,13 +551,28 @@ fn parse_fact( 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()?, - profile_id: layer.profile_id().to_owned(), }; let phase = parse_phase(fields.get("Phase")?, layer)?; let disposition = parse_disposition(fields.get("Disposition")?)?; @@ -517,6 +605,7 @@ fn parse_fact( terminal, evidence: evidence.reference.clone(), utc_millis, + extraction_profile, }; Some(if fact.utc_millis.is_some() { ParsedFact::Valid(fact) @@ -584,7 +673,7 @@ fn reduce_transaction( mut facts: Vec, ordering_poisoned: bool, artifacts: &[&SccmServerArtifactAssessment], -) -> Option { +) -> Option { if !ordering_poisoned { facts.sort_by_key(|fact| fact.utc_millis); } @@ -621,14 +710,18 @@ fn reduce_transaction( .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 gaps = artifacts + 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() @@ -643,72 +736,113 @@ fn reduce_transaction( } 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 !gaps.is_empty() || contradictory || !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 { - ( - 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 request = matches!( - state, - ProviderAdminServiceState::Incomplete | ProviderAdminServiceState::BlockedOrDeferred - ) - .then(|| artifact_request(key.layer)); + 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}:{}", + "{}:{request_handle}:{operation_handle}:{}:{}", layer_name(key.layer), + key.host_handle, key.endpoint_handle ); - Some(ProviderAdminServiceTransaction { + let extraction_profile = facts.first()?.extraction_profile.clone(); + let transaction = ProviderAdminServiceTransaction { transaction_id, layer: key.layer, producer_role: key.layer.role(), @@ -718,11 +852,8 @@ fn reduce_transaction( operation_handle, endpoint_handle: key.endpoint_handle, producer_host_handle: key.host_handle, - confidence: SccmKeyConfidence::Exact, - extraction_profile_id: match key.profile_id.as_str() { - PROVIDER_PROFILE => PROVIDER_PROFILE, - _ => ADMIN_PROFILE, - }, + confidence: SccmKeyConfidence::Low, + extraction_profile, }, topology_compatibility: ProviderAdminServiceTopologyCompatibility::Exact, timestamp_ordering: if ordering_usable { @@ -730,11 +861,7 @@ fn reduce_transaction( } else { ProviderAdminServiceTimestampOrdering::Unusable }, - correlation_eligible: conclusive - && matches!( - state, - ProviderAdminServiceState::Succeeded | ProviderAdminServiceState::Failed - ), + correlation_eligible: false, state, classification, confidence, @@ -742,12 +869,140 @@ fn reduce_transaction( terminal_evidence: terminal_success || terminal_failure, last_successful_phase, coverage_gap_artifact_ids: gaps, - next_artifact_request: request, + 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; @@ -826,21 +1081,52 @@ fn source_local_observations( fn global_artifact_requests( artifacts: &[&SccmServerArtifactAssessment], -) -> Vec { - let mut layers = artifacts +) -> Vec { + let mut requests = artifacts .iter() .filter_map(|artifact| { let layer = transaction_layer(artifact)?; (artifact.state != SccmCoverageState::Captured || artifact.fragment_complete == Some(false)) - .then_some(layer) + .then(|| scoped_artifact_request(artifact, layer))? }) - .collect::>(); - layers - .pop_first() - .map(artifact_request) - .into_iter() - .collect() + .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 { @@ -909,10 +1195,13 @@ fn display_layer(layer: ProviderAdminServiceLayer) -> &'static str { } } -fn role_name(role: &SccmRole) -> &'static str { - match role { - SccmRole::Provider => "provider", - SccmRole::AdminService => "adminService", - _ => "other", +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/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 index 313191842..32f9c6faa 100644 --- 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 @@ -1,19 +1,88 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "admin-service-access-denied", - "expectedCoverage": [ + "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", - "sourceId": "server-admin-service", + "producerHostHandle": "synthetic:host:admin-service-01", "producerRole": "adminService", - "state": "accessDenied" + "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" } ], - "expectedTransactions": [], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": { - "logicalId": "adminService", - "role": "adminService" - } + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [], + "workflow": "providerAndAdminService" } 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 index d6ef3c086..dc6b5d73e 100644 --- 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 @@ -1,26 +1,173 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "admin-service-auth-failure", - "expectedCoverage": [ + "artifactRequests": [], + "coverage": [ { "artifactId": "admin-auth-current", - "sourceId": "server-admin-service", + "producerHostHandle": "synthetic:host:admin-service-01", "producerRole": "adminService", - "state": "captured" + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" } ], - "expectedTransactions": [ + "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": [ { - "state": "failed", "classification": "confirmedFailure", "confidence": "high", - "timestampOrdering": "usable", - "terminalEvidence": true, + "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", - "lastSuccessfulPhase": "receive" + "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" } ], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": null + "workflow": "providerAndAdminService" } 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 index 05fe0c50c..f15893efa 100644 --- 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 @@ -1,26 +1,213 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "admin-service-backend-failure", - "expectedCoverage": [ + "artifactRequests": [], + "coverage": [ { "artifactId": "admin-backend-current", - "sourceId": "server-admin-service", + "producerHostHandle": "synthetic:host:admin-service-01", "producerRole": "adminService", - "state": "captured" + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" } ], - "expectedTransactions": [ + "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": [ { - "state": "failed", "classification": "confirmedFailure", "confidence": "high", - "timestampOrdering": "usable", - "terminalEvidence": true, + "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", - "lastSuccessfulPhase": "route" + "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" } ], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": null + "workflow": "providerAndAdminService" } 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 index fb3255615..e101c8b7b 100644 --- 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 @@ -1,19 +1,88 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "admin-service-parse-failed", - "expectedCoverage": [ + "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", - "sourceId": "server-admin-service", + "producerHostHandle": "synthetic:host:admin-service-01", "producerRole": "adminService", - "state": "parseFailed" + "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" } ], - "expectedTransactions": [], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": { - "logicalId": "adminService", - "role": "adminService" - } + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [], + "workflow": "providerAndAdminService" } 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 index 829ede742..5f9deae25 100644 --- 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 @@ -1,19 +1,88 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "admin-service-skipped", - "expectedCoverage": [ + "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", - "sourceId": "server-admin-service", + "producerHostHandle": "synthetic:host:admin-service-01", "producerRole": "adminService", - "state": "skipped" + "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" } ], - "expectedTransactions": [], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": { - "logicalId": "adminService", - "role": "adminService" - } + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [], + "workflow": "providerAndAdminService" } 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 index 26fe175b8..3b9218db4 100644 --- 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 @@ -1,26 +1,161 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "admin-service-success", - "expectedCoverage": [ + "artifactRequests": [], + "coverage": [ { "artifactId": "admin-success-current", - "sourceId": "server-admin-service", + "producerHostHandle": "synthetic:host:admin-service-01", "producerRole": "adminService", - "state": "captured" + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" } ], - "expectedTransactions": [ + "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": [ { - "state": "succeeded", "classification": "success", "confidence": "high", - "timestampOrdering": "usable", - "terminalEvidence": true, + "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" + "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" } ], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": null + "workflow": "providerAndAdminService" } 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 index f5aa65055..eba08c0b8 100644 --- 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 @@ -1,29 +1,195 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "blocked-deferred", - "expectedCoverage": [ + "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", - "sourceId": "server-admin-service", + "producerHostHandle": "synthetic:host:admin-service-01", "producerRole": "adminService", - "state": "captured" + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" } ], - "expectedTransactions": [ + "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": [ { - "state": "blockedOrDeferred", "classification": "blockedOrDeferred", "confidence": "moderate", - "timestampOrdering": "usable", - "terminalEvidence": false, + "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" + "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" } ], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": { - "logicalId": "adminService", - "role": "adminService" - } + "workflow": "providerAndAdminService" } 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 index 8f5dd5006..4a6941b8a 100644 --- 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 @@ -1,29 +1,233 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "contradictory-evidence", - "expectedCoverage": [ + "artifactRequests": [], + "coverage": [ { "artifactId": "contradictory-provider-current", - "sourceId": "server-provider", + "producerHostHandle": "synthetic:host:provider-01", "producerRole": "provider", - "state": "captured" + "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" } ], - "expectedTransactions": [ + "profiles": [ { - "state": "incomplete", - "classification": "insufficientEvidence", + "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", - "timestampOrdering": "usable", - "terminalEvidence": true, + "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" + "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" } ], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": { - "logicalId": "smsprov", - "role": "provider" - } + "workflow": "providerAndAdminService" } 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 index a1622e2a7..6f6fe200e 100644 --- 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 @@ -1,34 +1,179 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "iis-supplemental", - "expectedCoverage": [ + "artifactRequests": [], + "coverage": [ { "artifactId": "admin-iis-current", - "sourceId": "server-admin-service", + "producerHostHandle": "synthetic:host:admin-service-01", "producerRole": "adminService", - "state": "captured" + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" }, { "artifactId": "iis-supplemental-current", - "sourceId": "server-admin-service-iis", + "producerHostHandle": "synthetic:host:admin-service-01", "producerRole": "adminService", - "state": "captured" + "sourceId": "server-admin-service-iis", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" } ], - "expectedTransactions": [ + "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": [ { - "state": "succeeded", "classification": "success", "confidence": "high", - "timestampOrdering": "usable", - "terminalEvidence": true, + "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" + "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" } ], - "expectedSourceLocalKinds": [ - "supplementalOnly" - ], - "expectedArtifactRequest": null + "workflow": "providerAndAdminService" } 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 index 1adc9c94b..9d13617a7 100644 --- 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 @@ -1,29 +1,155 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "incomplete", - "expectedCoverage": [ + "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", - "sourceId": "server-admin-service", + "producerHostHandle": "synthetic:host:admin-service-01", "producerRole": "adminService", - "state": "captured" + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" } ], - "expectedTransactions": [ + "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": [ { - "state": "incomplete", "classification": "insufficientEvidence", "confidence": "low", - "timestampOrdering": "usable", - "terminalEvidence": false, + "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" + "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" } ], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": { - "logicalId": "adminService", - "role": "adminService" - } + "workflow": "providerAndAdminService" } 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 index c793c0535..839e71226 100644 --- 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 @@ -1,44 +1,311 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "privacy-redaction", - "expectedCoverage": [ + "artifactRequests": [], + "coverage": [ { "artifactId": "privacy-admin-current", - "sourceId": "server-admin-service", + "producerHostHandle": "synthetic:host:admin-service-01", "producerRole": "adminService", - "state": "captured" + "sourceId": "server-admin-service", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:admin-service-01" }, { "artifactId": "privacy-provider-current", - "sourceId": "server-provider", + "producerHostHandle": "synthetic:host:provider-01", "producerRole": "provider", - "state": "captured" + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:provider-01" } ], - "expectedTransactions": [ + "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": [ { - "state": "succeeded", "classification": "success", "confidence": "high", - "timestampOrdering": "usable", - "terminalEvidence": true, + "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" + "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" }, { - "state": "succeeded", "classification": "success", "confidence": "high", - "timestampOrdering": "usable", - "terminalEvidence": true, + "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" + "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" } ], - "expectedSourceLocalKinds": [ - "privacyRedacted", - "privacyRedacted" - ], - "expectedArtifactRequest": null + "workflow": "providerAndAdminService" } 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 index 4de165c3f..29f827351 100644 --- 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 @@ -1,26 +1,173 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "provider-authz-denied", - "expectedCoverage": [ + "artifactRequests": [], + "coverage": [ { "artifactId": "provider-authz-current", - "sourceId": "server-provider", + "producerHostHandle": "synthetic:host:provider-01", "producerRole": "provider", - "state": "captured" + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:provider-01" } ], - "expectedTransactions": [ + "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": [ { - "state": "failed", "classification": "confirmedFailure", "confidence": "high", - "timestampOrdering": "usable", - "terminalEvidence": true, + "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", - "lastSuccessfulPhase": "receive" + "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" } ], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": null + "workflow": "providerAndAdminService" } 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 index e6eb09066..e72247fa9 100644 --- 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 @@ -1,26 +1,193 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "provider-query-failure", - "expectedCoverage": [ + "artifactRequests": [], + "coverage": [ { "artifactId": "provider-query-current", - "sourceId": "server-provider", + "producerHostHandle": "synthetic:host:provider-01", "producerRole": "provider", - "state": "captured" + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:provider-01" } ], - "expectedTransactions": [ + "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": [ { - "state": "failed", "classification": "confirmedFailure", "confidence": "high", - "timestampOrdering": "usable", - "terminalEvidence": true, + "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", - "lastSuccessfulPhase": "authenticateOrAuthorize" + "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" } ], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": null + "workflow": "providerAndAdminService" } 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 index df84553a7..0af038662 100644 --- 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 @@ -1,26 +1,223 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "provider-retry", - "expectedCoverage": [ + "artifactRequests": [], + "coverage": [ { "artifactId": "provider-retry-current", - "sourceId": "server-provider", + "producerHostHandle": "synthetic:host:provider-01", "producerRole": "provider", - "state": "captured" + "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" } ], - "expectedTransactions": [ + "profiles": [ { - "state": "succeeded", - "classification": "success", + "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", - "timestampOrdering": "usable", - "terminalEvidence": true, + "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" + "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" } ], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": null + "workflow": "providerAndAdminService" } 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 index 1282e21ee..a5a70992c 100644 --- 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 @@ -1,19 +1,88 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "provider-source-absent", - "expectedCoverage": [ + "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", - "sourceId": "server-provider", + "producerHostHandle": "synthetic:host:provider-01", "producerRole": "provider", - "state": "absent" + "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" } ], - "expectedTransactions": [], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": { - "logicalId": "smsprov", - "role": "provider" - } + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [], + "workflow": "providerAndAdminService" } 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 index 93036ba5b..120ee7dcf 100644 --- 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 @@ -1,21 +1,97 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "provider-source-capped", - "expectedCoverage": [ + "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", - "sourceId": "server-provider", + "producerHostHandle": "synthetic:host:provider-01", "producerRole": "provider", - "state": "capped" + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "state": "capped", + "workflowSubjectHandle": "synthetic:subject:provider-01" } ], - "expectedTransactions": [], - "expectedSourceLocalKinds": [ - "rotationFragment" + "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" + } ], - "expectedArtifactRequest": { - "logicalId": "smsprov", - "role": "provider" - } + "supportState": "syntheticProfileOnly", + "transactions": [], + "workflow": "providerAndAdminService" } 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 index 544e14c0e..e579fdb4e 100644 --- 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 @@ -1,19 +1,88 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "provider-source-unsupported", - "expectedCoverage": [ + "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", - "sourceId": "server-provider", + "producerHostHandle": "synthetic:host:provider-01", "producerRole": "provider", - "state": "unsupported" + "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" } ], - "expectedTransactions": [], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": { - "logicalId": "smsprov", - "role": "provider" - } + "sourceLocalObservations": [], + "supportState": "syntheticProfileOnly", + "transactions": [], + "workflow": "providerAndAdminService" } 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 index 0f99cfc72..45f5219f4 100644 --- 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 @@ -1,26 +1,147 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "provider-success", - "expectedCoverage": [ + "artifactRequests": [], + "coverage": [ { "artifactId": "provider-success-current", - "sourceId": "server-provider", + "producerHostHandle": "synthetic:host:provider-01", "producerRole": "provider", - "state": "captured" + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:provider-01" } ], - "expectedTransactions": [ + "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": [ { - "state": "succeeded", "classification": "success", "confidence": "high", - "timestampOrdering": "usable", - "terminalEvidence": true, + "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" + "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" } ], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": null + "workflow": "providerAndAdminService" } 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 index 209fd093b..1c6e8ea87 100644 --- 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 @@ -1,29 +1,195 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "provider-timeout", - "expectedCoverage": [ + "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", - "sourceId": "server-provider", + "producerHostHandle": "synthetic:host:provider-01", "producerRole": "provider", - "state": "captured" + "sourceId": "server-provider", + "sourceVersion": "5.00.TEST", + "state": "captured", + "workflowSubjectHandle": "synthetic:subject:provider-01" } ], - "expectedTransactions": [ + "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": [ { - "state": "incomplete", "classification": "insufficientEvidence", "confidence": "low", - "timestampOrdering": "unusable", - "terminalEvidence": false, + "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" + "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" } ], - "expectedSourceLocalKinds": [], - "expectedArtifactRequest": { - "logicalId": "smsprov", - "role": "provider" - } + "workflow": "providerAndAdminService" } 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 index 0b82f1b1a..0fdae4b9f 100644 --- 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 @@ -1,28 +1,150 @@ { - "fixtureContractVersion": 1, - "workflow": "providerAndAdminService", - "scenario": "rotation-boundary", - "expectedCoverage": [ + "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", - "sourceId": "server-provider", + "producerHostHandle": "synthetic:host:provider-01", "producerRole": "provider", - "state": "parseFailed" + "sourceId": "server-provider", + "sourceVersion": null, + "state": "parseFailed", + "workflowSubjectHandle": "synthetic:subject:provider-01" }, { "artifactId": "rotation-02-lo", - "sourceId": "server-provider", + "producerHostHandle": "synthetic:host:provider-01", "producerRole": "provider", - "state": "parseFailed" + "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" } ], - "expectedTransactions": [], - "expectedSourceLocalKinds": [ - "rotationFragment", - "rotationFragment" + "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" + } ], - "expectedArtifactRequest": { - "logicalId": "smsprov", - "role": "provider" - } + "supportState": "syntheticProfileOnly", + "transactions": [], + "workflow": "providerAndAdminService" } 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 index 5e8e6f293..976f5c85e 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_provider_and_admin_service.rs @@ -8,7 +8,7 @@ use cmtraceopen_parser::sccm::server::windows::{ ProviderAdminServiceSupportState, ProviderAdminServiceTimestampOrdering, SccmServerArtifactPayload, SccmServerIntakeAssessment, SccmServerIntakeError, }; -use cmtraceopen_parser::sccm::{SccmConfidence, SccmCoverageState, SccmRole}; +use cmtraceopen_parser::sccm::{SccmCoverageState, SccmKeyConfidence, SccmRole}; use serde_json::{json, Value}; const SCENARIOS: [&str; 20] = [ @@ -80,6 +80,17 @@ 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")) @@ -88,6 +99,13 @@ fn expected(scenario: &str) -> Value { .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 { @@ -107,64 +125,6 @@ fn all_provider_and_admin_service_fixtures_enter_through_canonical_intake() { } } -#[derive(Clone, Copy)] -struct ExpectedTransaction { - state: ProviderAdminServiceState, - classification: ProviderAdminServiceClassification, - confidence: SccmConfidence, - ordering: ProviderAdminServiceTimestampOrdering, -} - -fn expected_transactions(scenario: &str) -> &'static [ExpectedTransaction] { - use ProviderAdminServiceClassification as Class; - use ProviderAdminServiceState as State; - use ProviderAdminServiceTimestampOrdering as Ordering; - const SUCCESS: ExpectedTransaction = ExpectedTransaction { - state: State::Succeeded, - classification: Class::Success, - confidence: SccmConfidence::High, - ordering: Ordering::Usable, - }; - const FAILURE: ExpectedTransaction = ExpectedTransaction { - state: State::Failed, - classification: Class::ConfirmedFailure, - confidence: SccmConfidence::High, - ordering: Ordering::Usable, - }; - const BLOCKED: ExpectedTransaction = ExpectedTransaction { - state: State::BlockedOrDeferred, - classification: Class::BlockedOrDeferred, - confidence: SccmConfidence::Moderate, - ordering: Ordering::Usable, - }; - const INCOMPLETE: ExpectedTransaction = ExpectedTransaction { - state: State::Incomplete, - classification: Class::InsufficientEvidence, - confidence: SccmConfidence::Low, - ordering: Ordering::Usable, - }; - const UNORDERED: ExpectedTransaction = ExpectedTransaction { - state: State::Incomplete, - classification: Class::InsufficientEvidence, - confidence: SccmConfidence::Low, - ordering: Ordering::Unusable, - }; - match scenario { - "admin-service-auth-failure" - | "admin-service-backend-failure" - | "provider-authz-denied" - | "provider-query-failure" => &[FAILURE], - "admin-service-success" | "iis-supplemental" | "provider-retry" | "provider-success" => { - &[SUCCESS] - } - "blocked-deferred" => &[BLOCKED], - "contradictory-evidence" | "incomplete" => &[INCOMPLETE], - "privacy-redaction" => &[SUCCESS, SUCCESS], - "provider-timeout" => &[UNORDERED], - _ => &[], - } -} - #[test] fn complete_fixture_matrix_runs_through_the_production_analyzer() { for scenario in SCENARIOS { @@ -173,139 +133,72 @@ fn complete_fixture_matrix_runs_through_the_production_analyzer() { let public = serde_json::to_value(&analysis).unwrap_or_else(|error| { panic!("{scenario}: shared review contract must serialize: {error}") }); - let expected = expected_transactions(scenario); - assert_eq!(analysis.transactions.len(), expected.len(), "{scenario}"); - for (transaction, expected) in analysis.transactions.iter().zip(expected) { - assert_eq!(transaction.state, expected.state, "{scenario}"); - assert_eq!( - transaction.classification, expected.classification, - "{scenario}" - ); - assert_eq!(transaction.confidence, expected.confidence, "{scenario}"); - assert_eq!( - transaction.confidence_ceiling, expected.confidence, - "{scenario}" - ); - assert_eq!( - transaction.timestamp_ordering, expected.ordering, - "{scenario}" - ); - assert_eq!(transaction.source_version, "5.00.TEST", "{scenario}"); - assert_eq!( - transaction.producer_role, - match transaction.layer { - ProviderAdminServiceLayer::Provider => SccmRole::Provider, - ProviderAdminServiceLayer::AdminService => SccmRole::AdminService, - }, - "{scenario}" - ); - assert_eq!( - transaction.correlation_eligible, - matches!( - transaction.state, - ProviderAdminServiceState::Succeeded | ProviderAdminServiceState::Failed - ), - "{scenario}" - ); - assert_eq!( - transaction.next_artifact_request.is_some(), - matches!( - transaction.state, - ProviderAdminServiceState::Incomplete - | ProviderAdminServiceState::BlockedOrDeferred - ), - "{scenario}" - ); - } - - let expected_request = matches!( - scenario, - "admin-service-access-denied" - | "admin-service-parse-failed" - | "admin-service-skipped" - | "blocked-deferred" - | "contradictory-evidence" - | "incomplete" - | "provider-source-absent" - | "provider-source-capped" - | "provider-source-unsupported" - | "provider-timeout" - | "rotation-boundary" - ); - assert_eq!( - analysis.artifact_requests.len(), - usize::from(expected_request), - "{scenario}" - ); - - let actual_coverage = public["coverage"] - .as_array() - .expect("public coverage") - .iter() - .map(|coverage| { - json!({ - "artifactId": coverage["artifactId"], - "sourceId": coverage["sourceId"], - "producerRole": coverage["producerRole"], - "state": coverage["state"], - }) - }) - .collect::>(); - assert_eq!( - Value::Array(actual_coverage), - expected_contract["expectedCoverage"], - "{scenario}" - ); - - let actual_transactions = public["transactions"] - .as_array() - .expect("public transactions") - .iter() - .map(|transaction| { - json!({ - "layer": transaction["layer"], - "state": transaction["state"], - "classification": transaction["classification"], - "confidence": transaction["confidence"], - "timestampOrdering": transaction["timestampOrdering"], - "terminalEvidence": transaction["terminalEvidence"], - "lastSuccessfulPhase": transaction["lastSuccessfulPhase"], - }) - }) - .collect::>(); - assert_eq!( - Value::Array(actual_transactions), - expected_contract["expectedTransactions"], - "{scenario}" - ); - - let actual_local_kinds = public["sourceLocalObservations"] - .as_array() - .expect("source-local observations") - .iter() - .map(|observation| observation["kind"].clone()) - .collect::>(); - assert_eq!( - Value::Array(actual_local_kinds), - expected_contract["expectedSourceLocalKinds"], - "{scenario}" - ); + assert_exact_oracle(scenario, &public, &expected_contract); + } +} - let actual_request = public["artifactRequests"] - .as_array() - .expect("artifact requests") - .first() - .map(|request| { - json!({ - "logicalId": request["logicalId"], - "role": request["role"], - }) - }) - .unwrap_or(Value::Null); - assert_eq!( - actual_request, expected_contract["expectedArtifactRequest"], - "{scenario}" - ); +#[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}"); } } @@ -346,12 +239,24 @@ fn phase_reduction_covers_success_failure_deferred_recovery_and_contradiction() 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::Incomplete + ProviderAdminServiceState::Contradictory + ); + assert_eq!( + contradiction.transactions[0].classification, + ProviderAdminServiceClassification::ContradictoryEvidence ); assert!(!contradiction.transactions[0].correlation_eligible); @@ -367,7 +272,7 @@ fn phase_reduction_covers_success_failure_deferred_recovery_and_contradiction() } #[test] -fn one_artifact_with_two_exact_keys_produces_two_transactions() { +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 @@ -394,6 +299,9 @@ fn one_artifact_with_two_exact_keys_produces_two_transactions() { .transactions .iter() .all(|transaction| transaction.state == ProviderAdminServiceState::Succeeded)); + assert!(analysis.transactions.iter().all(|transaction| { + transaction.key.confidence == SccmKeyConfidence::Low && !transaction.correlation_eligible + })); } #[test] @@ -461,10 +369,125 @@ fn coverage_gaps_are_scoped_to_the_exact_topology_subject() { transaction.coverage_gap_artifact_ids, vec!["coverage-provider-capped"] ); - assert!(transaction.next_artifact_request.is_some()); + 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"); 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 index 66dd743ef..7f9afa473 100644 --- 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 @@ -3,7 +3,7 @@ use std::fs; use std::path::{Path, PathBuf}; use cmtraceopen_parser::sccm::{classify_artifact_name, SccmArtifactFamily, SccmRole}; -use serde_json::Value; +use serde_json::{Map, Value}; const SCENARIOS: [&str; 20] = [ "admin-service-access-denied", @@ -54,25 +54,54 @@ fn actual_scenarios() -> BTreeSet { .collect() } -fn private_shape(value: &Value) -> bool { - match value { - Value::String(value) => { - let lowercase = value.to_ascii_lowercase(); - lowercase.contains("bearer ") - || lowercase.contains("select ") - || lowercase.contains("@example") - || lowercase.contains("requestid") - || lowercase.contains("operationhandle") - || lowercase.contains("endpointid") - || lowercase.contains("preparationonly") - || value.contains("99999999-9999-9999-9999-999999999999") +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()); + } + } } - Value::Array(values) => values.iter().any(private_shape), - Value::Object(values) => values.iter().any(|(name, value)| { - private_shape(&Value::String(name.clone())) || private_shape(value) - }), - _ => false, } + tokens } #[test] @@ -84,88 +113,324 @@ fn corpus_has_the_exact_reviewed_scenario_matrix() { } #[test] -fn manifests_are_canonical_server_intake_inputs_with_sealed_roles_and_paths() { +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!(manifest.get("scenario").is_none(), "{scenario}"); - assert!(manifest.get("bundle").is_none(), "{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 = artifact["producerRole"].as_str().expect("producer role"); - let expected_role = if source_id == "server-provider" { - "provider" - } else { - "adminService" + 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!(role, expected_role, "{scenario}: {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"]["role"], expected_role, + artifact["workflowSubject"]["instanceHandle"], subject, "{scenario}" ); + let basename = artifact["originalBasename"].as_str().expect("basename"); assert!( - artifact["originalPath"] - .as_str() - .is_some_and(|path| path.starts_with("REDACTED_")), - "{scenario}" + 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" ); - for obsolete in [ - "layer", - "endpointId", - "diagnosticUse", - "sanitizedSourcePath", - "pathFingerprint", - ] { - assert!( - artifact.get(obsolete).is_none(), - "{scenario}: obsolete {obsolete}" - ); - } + assert!(artifact["originalPath"] + .as_str() + .is_some_and(|path| path.starts_with("REDACTED_"))); - let state = artifact["captureState"].as_str().expect("coverage state"); - if matches!(state, "captured" | "capped" | "parseFailed") { + 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 size = fs::metadata(&payload) - .unwrap_or_else(|error| panic!("{}: {error}", payload.display())) - .len(); - assert_eq!(artifact["bytesCopied"].as_u64(), Some(size), "{scenario}"); + 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["fixtureContractVersion"], 1, "{scenario}"); + 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["scenario"], scenario, "{scenario}"); - assert!( - !private_shape(&expected), - "{scenario}: private or obsolete expected shape" - ); - assert!(expected["expectedCoverage"].is_array(), "{scenario}"); - assert!(expected["expectedTransactions"].is_array(), "{scenario}"); - assert!( - expected["expectedSourceLocalKinds"].is_array(), + 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); diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 78fa136e7..d71cb4dd8 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -4214,6 +4214,16 @@ fn finding_access_denied_coverage_only_cannot_substantiate_an_outcome_class() { SccmConfidence::Moderate, "confirmedFailure", ), + ( + SccmFindingClass::Recovered, + SccmConfidence::High, + "recovered", + ), + ( + SccmFindingClass::ContradictoryEvidence, + SccmConfidence::Low, + "contradictoryEvidence", + ), ( SccmFindingClass::BlockedOrDeferred, SccmConfidence::Low, From 76deb045b107327deb356f9fa0dadbaa3e6db949 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 05:19:48 -0400 Subject: [PATCH 407/422] feat(sccm): add production evidence correlation --- .../src/sccm/correlation.rs | 996 +++++++++++ .../src/sccm/correlation_tests.rs | 1000 +++++++++++ crates/cmtraceopen-parser/src/sccm/mod.rs | 2 + .../server/windows/software_update_point.rs | 16 +- .../tests/fixtures/sccm/correlation/README.md | 35 +- .../adversarial-matrix.json | 626 ++----- .../sccm/correlation/pair-registry.json | 43 +- .../adversarial-matrix.json | 627 ++----- .../shared/adversarial-matrix.json | 55 +- .../adversarial-matrix.json | 204 +++ .../tests/sccm_correlation_contract.rs | 1501 ++++------------- ...6-08-04-sccm-333-production-correlation.md | 121 ++ library.md | 2 + 13 files changed, 2979 insertions(+), 2249 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/sccm/correlation.rs create mode 100644 crates/cmtraceopen-parser/src/sccm/correlation_tests.rs create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/updates_software_update_point/adversarial-matrix.json create mode 100644 docs/superpowers/plans/2026-08-04-sccm-333-production-correlation.md diff --git a/crates/cmtraceopen-parser/src/sccm/correlation.rs b/crates/cmtraceopen-parser/src/sccm/correlation.rs new file mode 100644 index 000000000..be822958e --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/correlation.rs @@ -0,0 +1,996 @@ +//! 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, + 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.observation_id + .to_ascii_lowercase() + .contains("rotation") + }), + 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/mod.rs b/crates/cmtraceopen-parser/src/sccm/mod.rs index 88c9c975b..210f64dcb 100644 --- a/crates/cmtraceopen-parser/src/sccm/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/mod.rs @@ -1,5 +1,6 @@ pub mod catalog; pub mod client; +pub mod correlation; mod evidence; mod findings; mod ingest; @@ -11,6 +12,7 @@ mod signals; pub use catalog::*; pub use client::*; +pub use correlation::*; pub use findings::*; pub use ingest::*; pub use keys::*; 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 index 9c2ccf521..0b8e6bdec 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/software_update_point.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/software_update_point.rs @@ -8,7 +8,9 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::Serialize; -use crate::sccm::{SccmCoverageState, SccmEvidence, SccmRole, SccmTimeOrderingState}; +use crate::sccm::{ + SccmCoverageState, SccmEvidence, SccmRole, SccmTimeOrderingState, SccmTimestamp, +}; use super::{SccmServerArtifactAssessment, SccmServerIntakeAssessment}; @@ -196,6 +198,12 @@ pub struct SccmSoftwareUpdatePointObservation { 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, } @@ -717,6 +725,12 @@ fn reduce_transaction( 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()], } }) diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md index 790ce25bf..22142500f 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/README.md @@ -1,30 +1,19 @@ -# SCCM correlation adversarial contract fixtures +# SCCM production correlation contract fixtures -These fixtures prepare issue #333's false-causality contract. They do not implement correlation, expose a production API, parse raw logs, or promote either pair to RuleValidated. +These fixtures are the executable production contract for issue #333. They cover exactly three independently accepted pairs: -`pair-registry.json` is deliberately non-executable. Policy to Management Point (`#321` to `#328`) and content to Distribution Point (`#322` to `#329`) are `contractPrepared`; their production flag, RuleValidated flag, and implementation module remain false/empty. Updates to SUP (`#323` to `#330`) is Candidate only and has no pair matrix or implementation permission. +- policy to Management Point (`#321` to `#328`); +- content to Distribution Point (`#322` to `#329`); +- updates to Software Update Point (`#323` to `#330`). -The shared matrix defines thirteen mandatory guards. Each first-pair matrix instantiates every guard in a pair-specific adversarial scenario: +`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. -- missing client and missing server counterparts; -- same-time evidence without an exact key; -- conflicting exact keys; -- incompatible site, MP, DP, or role topology; -- content/profile version mismatch; -- unknown extraction profile; -- invalid timestamp offset; -- rotation-split and partial capture; -- unrelated terminal server failure; -- private-marker redaction; -- reordered input. +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: -Every adversarial expected result forbids `exactCorroborated`, caps confidence below High, preserves source findings, and uses stable reason/request/result identifiers. Every declared guard must be demonstrated by the scenario's own input state, and each guard's `requiredOutputs` token is an executable predicate checked against the scenario's expected contract. Reordered input A/B cases encode one `orderedInputEvidence` manifest of side-tagged synthetic tokens, with B replaying A's multiset in opposite order, and pin identical expected public projections and result contracts. +- outcome, link strength, and confidence; +- sorted reason codes and triggered guards; +- the SHA-256 hash of the complete serialized analysis. -Fixture references have explicit status and are bound to their pair side: +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. -- `repo:` references point to already merged synthetic upstream fixture directories under the citing side's own corpus prefix; -- `issue:#329:` references mark DP scenarios whose public fact interface is not independently accepted; the #329 preparation corpus is merged on the program baseline, but until its fact interface is accepted (no production reducer exists for the #322/#329 pair) the content server side may use nothing else (or `absent`); -- `synthetic:` references describe future pair-local sanitized inputs and are only valid on merged sides; -- `absent` is an intentional missing counterpart, must agree with the declared one-sided coverage, and is never proof of failure. - -No raw Windows path, live hostname, user, tenant, token, or database data belongs here. The only identity-shaped values are reserved synthetic private markers used to prove that expected public projections omit them. +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 index 52cdd99c9..1ff09e51c 100644 --- 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 @@ -1,536 +1,204 @@ { "schemaVersion": "1.0.0", - "workflow": "contentDistributionPoint", + "pair": "contentDistributionPoint", "scenarios": [ { - "scenarioId": "content-client-only", - "guardIds": [ - "missing-server-counterpart" - ], - "clientIssue": "#322", - "serverIssue": "#329", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/location-missing", - "serverFixtureRef": "absent", - "profileState": "validated", - "keyRelation": "exact", - "topology": "incomplete", - "timestampProvenance": "missing", - "coverage": "clientOnly", - "rotation": "complete", - "terminalRelation": "missing", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "counterpartRequested", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "contentDistributionPoint" - }, - "expected": { - "linkStrengthCeiling": "unlinked", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "server-counterpart-missing" - ], - "artifactRequests": [ - "server-dp-content" - ], - "deterministicResultId": "corr-contract:content:client-only" - } + "scenarioId": "content-healthy", + "mutation": "healthy", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "86389112bae52c1a2bbeef7f697492567968dbdf622216f6d0ea6b4003c4d050" }, { "scenarioId": "content-conflicting-key", - "guardIds": [ + "mutation": "conflictingExactKey", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "exact-key-conflict" + ], + "expectedTriggeredGuards": [ "conflicting-exact-key" ], - "clientIssue": "#322", - "serverIssue": "#329", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success", - "serverFixtureRef": "issue:#329:different-content", - "profileState": "validated", - "keyRelation": "conflicting", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "contradictory", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "incompatible", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "contentDistributionPoint" - }, - "expected": { - "linkStrengthCeiling": "incompatible", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "exact-key-conflict" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:content:conflicting-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", - "guardIds": [ + "mutation": "invalidTimestampOffset", + "expectedOutcome": "notCausal", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "medium", + "expectedReasonCodes": [ + "ordering-unavailable" + ], + "expectedTriggeredGuards": [ "invalid-timestamp-offset" ], - "clientIssue": "#322", - "serverIssue": "#329", - "clientFixtureRef": "synthetic:content-client-invalid-offset", - "serverFixtureRef": "issue:#329:content-available", - "profileState": "validated", - "keyRelation": "exact", - "topology": "compatible", - "timestampProvenance": "invalidOffset", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [], - "expectedPublicProjection": { - "ordering": "unavailable", - "outcome": "notCausal", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "contentDistributionPoint" - }, - "expected": { - "linkStrengthCeiling": "exactPartial", - "confidenceCeiling": "medium", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "invalid-offset" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:content:invalid-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", - "guardIds": [ + "mutation": "partialCapture", + "expectedOutcome": "coverageGap", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "partial-source-coverage" + ], + "expectedTriggeredGuards": [ "partial-capture" ], - "clientIssue": "#322", - "serverIssue": "#329", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/incomplete", - "serverFixtureRef": "issue:#329:incomplete", - "profileState": "validated", - "keyRelation": "exact", - "topology": "incomplete", - "timestampProvenance": "missing", - "coverage": "partial", - "rotation": "complete", - "terminalRelation": "missing", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "coverageGap", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "contentDistributionPoint" - }, - "expected": { - "linkStrengthCeiling": "exactPartial", - "confidenceCeiling": "medium", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "partial-source-coverage" - ], - "artifactRequests": [ - "server-dp-content" - ], - "deterministicResultId": "corr-contract:content:partial-capture" - } + "expectedOutputSha256": "bebd381f8582b28f01f77deec1d06b45d342ff125548674d52d95c823d32b408" }, { "scenarioId": "content-redaction", - "guardIds": [ - "redaction-boundary" - ], - "clientIssue": "#322", - "serverIssue": "#329", - "clientFixtureRef": "synthetic:content-private-client-input", - "serverFixtureRef": "issue:#329:private-server-input", - "profileState": "validated", - "keyRelation": "exact", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [ - "LAB\\SyntheticDevice", - "https://dp.lab.invalid/content?sig=SYNTHETIC-SIGNATURE" - ], - "expectedPublicProjection": { - "clientHandle": "client-safe-001", - "dpHandle": "dp-safe-001", - "outcome": "notCausal", - "workflow": "contentDistributionPoint" - }, - "expected": { - "linkStrengthCeiling": "exactPartial", - "confidenceCeiling": "medium", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "redacted-contract-only" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:content:redaction" - } + "mutation": "redactionBoundary", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "aa8a7be47b8f6c549c13dd9f4931dcc1145845cb14456505c596b82ab25e0195" }, { "scenarioId": "content-reordered-input-a", - "guardIds": [ - "reordered-input" - ], - "orderedInputEvidence": [ - "client:content-location-request", - "client:content-download-summary", - "server:dp-content-availability", - "server:dp-transfer-ack" - ], - "clientIssue": "#322", - "serverIssue": "#329", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success", - "serverFixtureRef": "issue:#329:content-available", - "profileState": "validated", - "keyRelation": "exact", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "contractOnly", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "contentDistributionPoint" - }, - "expected": { - "linkStrengthCeiling": "exactPartial", - "confidenceCeiling": "medium", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "upstream-facts-not-stable" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:content:reordered" - } + "mutation": "reorderedInputA", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "2a5799dc5aee79fe823efa879f3eb0ac36eda19ed68436e59407086ea59d3834" }, { "scenarioId": "content-reordered-input-b", - "guardIds": [ - "reordered-input" - ], - "orderedInputEvidence": [ - "server:dp-transfer-ack", - "server:dp-content-availability", - "client:content-download-summary", - "client:content-location-request" - ], - "clientIssue": "#322", - "serverIssue": "#329", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success", - "serverFixtureRef": "issue:#329:content-available", - "profileState": "validated", - "keyRelation": "exact", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "contractOnly", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "contentDistributionPoint" - }, - "expected": { - "linkStrengthCeiling": "exactPartial", - "confidenceCeiling": "medium", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "upstream-facts-not-stable" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:content:reordered" - } + "mutation": "reorderedInputB", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "2a5799dc5aee79fe823efa879f3eb0ac36eda19ed68436e59407086ea59d3834" }, { "scenarioId": "content-rotation-split", - "guardIds": [ - "partial-capture", + "mutation": "rotationSplit", + "expectedOutcome": "coverageGap", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "rotation-incomplete" + ], + "expectedTriggeredGuards": [ "rotation-split" ], - "clientIssue": "#322", - "serverIssue": "#329", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/rotation-boundary", - "serverFixtureRef": "issue:#329:rotation-boundary", - "profileState": "validated", - "keyRelation": "exact", - "topology": "incomplete", - "timestampProvenance": "missing", - "coverage": "partial", - "rotation": "split", - "terminalRelation": "missing", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "coverageGap", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "contentDistributionPoint" - }, - "expected": { - "linkStrengthCeiling": "exactPartial", - "confidenceCeiling": "medium", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "rotation-split" - ], - "artifactRequests": [ - "server-dp-content" - ], - "deterministicResultId": "corr-contract:content:rotation-split" - } + "expectedOutputSha256": "c24696833c0315f587d13fc67c20c77babd14621cab4bbedc1089b2bbe4ddbc3" }, { "scenarioId": "content-same-time-no-key", - "guardIds": [ + "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" ], - "clientIssue": "#322", - "serverIssue": "#329", - "clientFixtureRef": "synthetic:content-same-time-client", - "serverFixtureRef": "issue:#329:same-time-server", - "profileState": "validated", - "keyRelation": "missing", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "contradictory", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "candidateOnly", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "contentDistributionPoint" - }, - "expected": { - "linkStrengthCeiling": "candidate", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "time-only-not-causal" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:content:same-time-no-key" - } - }, - { - "scenarioId": "content-server-only", - "guardIds": [ - "missing-client-counterpart" - ], - "clientIssue": "#322", - "serverIssue": "#329", - "clientFixtureRef": "absent", - "serverFixtureRef": "issue:#329:distribution-failure", - "profileState": "validated", - "keyRelation": "exact", - "topology": "incomplete", - "timestampProvenance": "missing", - "coverage": "serverOnly", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "counterpartRequested", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "contentDistributionPoint" - }, - "expected": { - "linkStrengthCeiling": "unlinked", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "client-counterpart-missing" - ], - "artifactRequests": [ - "client-content-location" - ], - "deterministicResultId": "corr-contract:content:server-only" - } - }, - { - "scenarioId": "content-topology-mismatch", - "guardIds": [ - "incompatible-topology" - ], - "clientIssue": "#322", - "serverIssue": "#329", - "clientFixtureRef": "synthetic:content-client-dp-a", - "serverFixtureRef": "issue:#329:content-dp-b", - "profileState": "validated", - "keyRelation": "exact", - "topology": "incompatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "incompatible", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "contentDistributionPoint" - }, - "expected": { - "linkStrengthCeiling": "incompatible", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "dp-topology-mismatch" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:content:topology-mismatch" - } + "expectedOutputSha256": "3e67925e5a00f66ca2066ddd2368c6a3f1717646b5b25c375b0a4aeb7e9a7e31" }, { "scenarioId": "content-unknown-profile", - "guardIds": [ + "mutation": "unknownExtractionProfile", + "expectedOutcome": "profileGap", + "expectedLinkStrength": "candidate", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "profile-unvalidated" + ], + "expectedTriggeredGuards": [ "unknown-extraction-profile" ], - "clientIssue": "#322", - "serverIssue": "#329", - "clientFixtureRef": "synthetic:content-client-unknown-profile", - "serverFixtureRef": "issue:#329:unknown-profile", - "profileState": "unknown", - "keyRelation": "exact", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "profileGap", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "contentDistributionPoint" - }, - "expected": { - "linkStrengthCeiling": "candidate", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "profile-unvalidated" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:content:unknown-profile" - } + "expectedOutputSha256": "18dd98da80a684ab7c41b6698bbe4a63e42a99a38371d3419c2b52c93f289507" }, { "scenarioId": "content-unrelated-terminal-error", - "guardIds": [ + "mutation": "unrelatedTerminalError", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "exact-key-conflict", + "unrelated-server-terminal" + ], + "expectedTriggeredGuards": [ "conflicting-exact-key", "unrelated-terminal-error" ], - "clientIssue": "#322", - "serverIssue": "#329", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success", - "serverFixtureRef": "issue:#329:unrelated-distribution-failure", - "profileState": "validated", - "keyRelation": "conflicting", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "unrelated", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "incompatible", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "contentDistributionPoint" - }, - "expected": { - "linkStrengthCeiling": "incompatible", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "unrelated-server-terminal" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:content:unrelated-terminal" - } + "expectedOutputSha256": "93521f8e12a33ce19659f1a0ce33c39145bc71f611db87f57fc9df9c8c419bac" }, { "scenarioId": "content-version-mismatch", - "guardIds": [ + "mutation": "versionMismatch", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "profile-version-mismatch" + ], + "expectedTriggeredGuards": [ "version-mismatch" ], - "clientIssue": "#322", - "serverIssue": "#329", - "clientFixtureRef": "synthetic:content-client-version-a", - "serverFixtureRef": "issue:#329:content-version-b", - "profileState": "versionMismatch", - "keyRelation": "versionMismatch", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "incompatible", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "contentDistributionPoint" - }, - "expected": { - "linkStrengthCeiling": "incompatible", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "content-version-mismatch" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:content: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 index b1ca955ad..a139eca44 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/pair-registry.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/pair-registry.json @@ -6,10 +6,10 @@ "workflow": "contentDistributionPoint", "clientIssue": "#322", "serverIssue": "#329", - "state": "contractPrepared", - "productionEnabled": false, - "ruleValidated": false, - "implementationModule": null, + "state": "ruleValidated", + "productionEnabled": true, + "ruleValidated": true, + "implementationModule": "sccm::correlation", "requiredGuardIds": [ "conflicting-exact-key", "incompatible-topology", @@ -25,21 +25,17 @@ "unrelated-terminal-error", "version-mismatch" ], - "blockers": [ - "#318 finding interface exact-head review pending", - "#322 public fact interface not implemented", - "#329 public fact interface not independently accepted" - ] + "blockers": [] }, { "pairId": "policy-management-point", "workflow": "policyManagementPoint", "clientIssue": "#321", "serverIssue": "#328", - "state": "contractPrepared", - "productionEnabled": false, - "ruleValidated": false, - "implementationModule": null, + "state": "ruleValidated", + "productionEnabled": true, + "ruleValidated": true, + "implementationModule": "sccm::correlation", "requiredGuardIds": [ "conflicting-exact-key", "incompatible-topology", @@ -55,21 +51,17 @@ "unrelated-terminal-error", "version-mismatch" ], - "blockers": [ - "#318 finding interface exact-head review pending", - "#321 public fact interface not implemented", - "#328 public fact interface not implemented" - ] + "blockers": [] }, { "pairId": "updates-software-update-point", "workflow": "updatesSoftwareUpdatePoint", "clientIssue": "#323", "serverIssue": "#330", - "state": "candidate", - "productionEnabled": false, - "ruleValidated": false, - "implementationModule": null, + "state": "ruleValidated", + "productionEnabled": true, + "ruleValidated": true, + "implementationModule": "sccm::correlation", "requiredGuardIds": [ "conflicting-exact-key", "incompatible-topology", @@ -85,12 +77,7 @@ "unrelated-terminal-error", "version-mismatch" ], - "blockers": [ - "#318 finding interface exact-head review pending", - "#323 source facts not independently accepted", - "#330 source facts not independently accepted", - "dedicated pair subplan not approved" - ] + "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 index 9892e8271..b7592fa79 100644 --- 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 @@ -1,537 +1,204 @@ { "schemaVersion": "1.0.0", - "workflow": "policyManagementPoint", + "pair": "policyManagementPoint", "scenarios": [ { - "scenarioId": "policy-client-only", - "guardIds": [ - "missing-server-counterpart" - ], - "clientIssue": "#321", - "serverIssue": "#328", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/request-auth-failure", - "serverFixtureRef": "absent", - "profileState": "validated", - "keyRelation": "exact", - "topology": "incomplete", - "timestampProvenance": "missing", - "coverage": "clientOnly", - "rotation": "complete", - "terminalRelation": "missing", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "counterpartRequested", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "policyManagementPoint" - }, - "expected": { - "linkStrengthCeiling": "unlinked", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "server-counterpart-missing" - ], - "artifactRequests": [ - "server-mp-auth", - "server-mp-policy" - ], - "deterministicResultId": "corr-contract:policy:client-only" - } + "scenarioId": "policy-healthy", + "mutation": "healthy", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "c3ebc9911c021195611ffbdc8c497f906c7fadf60f5dae419da95eddaf47a46b" }, { "scenarioId": "policy-conflicting-key", - "guardIds": [ + "mutation": "conflictingExactKey", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "exact-key-conflict" + ], + "expectedTriggeredGuards": [ "conflicting-exact-key" ], - "clientIssue": "#321", - "serverIssue": "#328", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete", - "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/unrelated-client-like-key", - "profileState": "validated", - "keyRelation": "conflicting", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "contradictory", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "incompatible", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "policyManagementPoint" - }, - "expected": { - "linkStrengthCeiling": "incompatible", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "exact-key-conflict" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:policy:conflicting-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", - "guardIds": [ + "mutation": "invalidTimestampOffset", + "expectedOutcome": "notCausal", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "medium", + "expectedReasonCodes": [ + "ordering-unavailable" + ], + "expectedTriggeredGuards": [ "invalid-timestamp-offset" ], - "clientIssue": "#321", - "serverIssue": "#328", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/contradictory-offset", - "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy", - "profileState": "validated", - "keyRelation": "exact", - "topology": "compatible", - "timestampProvenance": "invalidOffset", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [], - "expectedPublicProjection": { - "ordering": "unavailable", - "outcome": "notCausal", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "policyManagementPoint" - }, - "expected": { - "linkStrengthCeiling": "exactPartial", - "confidenceCeiling": "medium", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "invalid-offset" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:policy:invalid-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", - "guardIds": [ + "mutation": "partialCapture", + "expectedOutcome": "coverageGap", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "partial-source-coverage" + ], + "expectedTriggeredGuards": [ "partial-capture" ], - "clientIssue": "#321", - "serverIssue": "#328", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/incomplete", - "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/incomplete", - "profileState": "validated", - "keyRelation": "exact", - "topology": "incomplete", - "timestampProvenance": "missing", - "coverage": "partial", - "rotation": "complete", - "terminalRelation": "missing", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "coverageGap", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "policyManagementPoint" - }, - "expected": { - "linkStrengthCeiling": "exactPartial", - "confidenceCeiling": "medium", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "partial-source-coverage" - ], - "artifactRequests": [ - "server-mp-policy" - ], - "deterministicResultId": "corr-contract:policy:partial-capture" - } + "expectedOutputSha256": "1484872bdf1783a11cd1d8e9f43218eea755ff3ed3dc8b9234a96137b43bff1a" }, { "scenarioId": "policy-redaction", - "guardIds": [ - "redaction-boundary" - ], - "clientIssue": "#321", - "serverIssue": "#328", - "clientFixtureRef": "synthetic:policy-private-client-input", - "serverFixtureRef": "synthetic:policy-private-server-input", - "profileState": "validated", - "keyRelation": "exact", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [ - "LAB\\SyntheticUser", - "https://mp.lab.invalid/policy?token=SYNTHETIC-TOKEN" - ], - "expectedPublicProjection": { - "clientHandle": "client-safe-001", - "outcome": "notCausal", - "serverHandle": "mp-safe-001", - "workflow": "policyManagementPoint" - }, - "expected": { - "linkStrengthCeiling": "exactPartial", - "confidenceCeiling": "medium", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "redacted-contract-only" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:policy:redaction" - } + "mutation": "redactionBoundary", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "997942ab5a47842cbe50e126e03151b8fd9a2843843b26635bc083e0b7b88521" }, { "scenarioId": "policy-reordered-input-a", - "guardIds": [ - "reordered-input" - ], - "orderedInputEvidence": [ - "client:policy-request-assignments", - "client:policy-evaluation-summary", - "server:mp-policy-response", - "server:mp-endpoint-ack" - ], - "clientIssue": "#321", - "serverIssue": "#328", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete", - "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy", - "profileState": "validated", - "keyRelation": "exact", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "contractOnly", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "policyManagementPoint" - }, - "expected": { - "linkStrengthCeiling": "exactPartial", - "confidenceCeiling": "medium", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "upstream-facts-not-stable" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:policy:reordered" - } + "mutation": "reorderedInputA", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "7b9cc92b8084b0c61933c5fc606211e404c7f06dce9e742ae157d971589b2fd0" }, { "scenarioId": "policy-reordered-input-b", - "guardIds": [ - "reordered-input" - ], - "orderedInputEvidence": [ - "server:mp-endpoint-ack", - "server:mp-policy-response", - "client:policy-evaluation-summary", - "client:policy-request-assignments" - ], - "clientIssue": "#321", - "serverIssue": "#328", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete", - "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/healthy-policy", - "profileState": "validated", - "keyRelation": "exact", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "contractOnly", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "policyManagementPoint" - }, - "expected": { - "linkStrengthCeiling": "exactPartial", - "confidenceCeiling": "medium", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "upstream-facts-not-stable" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:policy:reordered" - } + "mutation": "reorderedInputB", + "expectedOutcome": "causalFinding", + "expectedLinkStrength": "exactCorroborated", + "expectedConfidence": "high", + "expectedReasonCodes": [], + "expectedTriggeredGuards": [], + "expectedOutputSha256": "7b9cc92b8084b0c61933c5fc606211e404c7f06dce9e742ae157d971589b2fd0" }, { "scenarioId": "policy-rotation-split", - "guardIds": [ - "partial-capture", + "mutation": "rotationSplit", + "expectedOutcome": "coverageGap", + "expectedLinkStrength": "exactPartial", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "rotation-incomplete" + ], + "expectedTriggeredGuards": [ "rotation-split" ], - "clientIssue": "#321", - "serverIssue": "#328", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/rotation-split", - "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/rotation-boundary", - "profileState": "validated", - "keyRelation": "exact", - "topology": "incomplete", - "timestampProvenance": "missing", - "coverage": "partial", - "rotation": "split", - "terminalRelation": "missing", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "coverageGap", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "policyManagementPoint" - }, - "expected": { - "linkStrengthCeiling": "exactPartial", - "confidenceCeiling": "medium", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "rotation-split" - ], - "artifactRequests": [ - "server-mp-policy" - ], - "deterministicResultId": "corr-contract:policy:rotation-split" - } + "expectedOutputSha256": "0e5cd3e0f384249e02cdb839ad211d2a450e29ccd6402a0bd570a1cdc98e1165" }, { "scenarioId": "policy-same-time-no-key", - "guardIds": [ + "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" ], - "clientIssue": "#321", - "serverIssue": "#328", - "clientFixtureRef": "synthetic:policy-same-time-client", - "serverFixtureRef": "synthetic:policy-same-time-server", - "profileState": "validated", - "keyRelation": "missing", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "contradictory", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "candidateOnly", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "policyManagementPoint" - }, - "expected": { - "linkStrengthCeiling": "candidate", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "time-only-not-causal" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:policy:same-time-no-key" - } - }, - { - "scenarioId": "policy-server-only", - "guardIds": [ - "missing-client-counterpart" - ], - "clientIssue": "#321", - "serverIssue": "#328", - "clientFixtureRef": "absent", - "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure", - "profileState": "validated", - "keyRelation": "exact", - "topology": "incomplete", - "timestampProvenance": "missing", - "coverage": "serverOnly", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "counterpartRequested", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "policyManagementPoint" - }, - "expected": { - "linkStrengthCeiling": "unlinked", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "client-counterpart-missing" - ], - "artifactRequests": [ - "client-policy-agent" - ], - "deterministicResultId": "corr-contract:policy:server-only" - } - }, - { - "scenarioId": "policy-topology-mismatch", - "guardIds": [ - "incompatible-topology" - ], - "clientIssue": "#321", - "serverIssue": "#328", - "clientFixtureRef": "synthetic:policy-client-site-lab", - "serverFixtureRef": "synthetic:policy-server-site-other", - "profileState": "validated", - "keyRelation": "exact", - "topology": "incompatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "incompatible", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "policyManagementPoint" - }, - "expected": { - "linkStrengthCeiling": "incompatible", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "site-or-mp-mismatch" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:policy:topology-mismatch" - } + "expectedOutputSha256": "7b031625aebbd2f82edd3aefe6a0ea6a8ce50f991bcd3a2befe149d9110fc3c8" }, { "scenarioId": "policy-unknown-profile", - "guardIds": [ + "mutation": "unknownExtractionProfile", + "expectedOutcome": "profileGap", + "expectedLinkStrength": "candidate", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "profile-unvalidated" + ], + "expectedTriggeredGuards": [ "unknown-extraction-profile" ], - "clientIssue": "#321", - "serverIssue": "#328", - "clientFixtureRef": "synthetic:policy-client-unknown-profile", - "serverFixtureRef": "synthetic:policy-server-unknown-profile", - "profileState": "unknown", - "keyRelation": "exact", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "profileGap", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "policyManagementPoint" - }, - "expected": { - "linkStrengthCeiling": "candidate", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "profile-unvalidated" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:policy:unknown-profile" - } + "expectedOutputSha256": "2a114d06ca9156e924216e8cec28ae4afe238d4b801640218061718e478e46ac" }, { "scenarioId": "policy-unrelated-terminal-error", - "guardIds": [ + "mutation": "unrelatedTerminalError", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "exact-key-conflict", + "unrelated-server-terminal" + ], + "expectedTriggeredGuards": [ "conflicting-exact-key", "unrelated-terminal-error" ], - "clientIssue": "#321", - "serverIssue": "#328", - "clientFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete", - "serverFixtureRef": "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/policy-failure", - "profileState": "validated", - "keyRelation": "conflicting", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "unrelated", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "incompatible", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "policyManagementPoint" - }, - "expected": { - "linkStrengthCeiling": "incompatible", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "unrelated-server-terminal" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:policy:unrelated-terminal" - } + "expectedOutputSha256": "ec6fb443597a7c99cdb90ef70f898039156a18bae55d3e22b9983a203bb3d4b7" }, { "scenarioId": "policy-version-mismatch", - "guardIds": [ + "mutation": "versionMismatch", + "expectedOutcome": "incompatible", + "expectedLinkStrength": "incompatible", + "expectedConfidence": "low", + "expectedReasonCodes": [ + "profile-version-mismatch" + ], + "expectedTriggeredGuards": [ "version-mismatch" ], - "clientIssue": "#321", - "serverIssue": "#328", - "clientFixtureRef": "synthetic:policy-client-profile-v1", - "serverFixtureRef": "synthetic:policy-server-profile-v2", - "profileState": "versionMismatch", - "keyRelation": "versionMismatch", - "topology": "compatible", - "timestampProvenance": "usable", - "coverage": "complete", - "rotation": "complete", - "terminalRelation": "corroborating", - "privateInputMarkers": [], - "expectedPublicProjection": { - "outcome": "incompatible", - "schemaVersion": "1.0.0", - "sourceFindingsPreserved": true, - "workflow": "policyManagementPoint" - }, - "expected": { - "linkStrengthCeiling": "incompatible", - "confidenceCeiling": "low", - "highConfidenceCauseAllowed": false, - "exactCorroboratedAllowed": false, - "sourceFindingsMutable": false, - "reasonCodes": [ - "profile-version-mismatch" - ], - "artifactRequests": [], - "deterministicResultId": "corr-contract:policy: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 index e0c4041ba..13829d93c 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/shared/adversarial-matrix.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/correlation/shared/adversarial-matrix.json @@ -5,7 +5,8 @@ "guardId": "conflicting-exact-key", "appliesTo": [ "contentDistributionPoint", - "policyManagementPoint" + "policyManagementPoint", + "updatesSoftwareUpdatePoint" ], "forbiddenStrengths": [ "exactCorroborated" @@ -22,7 +23,8 @@ "guardId": "incompatible-topology", "appliesTo": [ "contentDistributionPoint", - "policyManagementPoint" + "policyManagementPoint", + "updatesSoftwareUpdatePoint" ], "forbiddenStrengths": [ "exactCorroborated" @@ -39,7 +41,8 @@ "guardId": "invalid-timestamp-offset", "appliesTo": [ "contentDistributionPoint", - "policyManagementPoint" + "policyManagementPoint", + "updatesSoftwareUpdatePoint" ], "forbiddenStrengths": [ "exactCorroborated" @@ -56,7 +59,8 @@ "guardId": "missing-client-counterpart", "appliesTo": [ "contentDistributionPoint", - "policyManagementPoint" + "policyManagementPoint", + "updatesSoftwareUpdatePoint" ], "forbiddenStrengths": [ "exactCorroborated" @@ -73,7 +77,8 @@ "guardId": "missing-server-counterpart", "appliesTo": [ "contentDistributionPoint", - "policyManagementPoint" + "policyManagementPoint", + "updatesSoftwareUpdatePoint" ], "forbiddenStrengths": [ "exactCorroborated" @@ -90,7 +95,8 @@ "guardId": "partial-capture", "appliesTo": [ "contentDistributionPoint", - "policyManagementPoint" + "policyManagementPoint", + "updatesSoftwareUpdatePoint" ], "forbiddenStrengths": [ "exactCorroborated" @@ -107,14 +113,11 @@ "guardId": "redaction-boundary", "appliesTo": [ "contentDistributionPoint", - "policyManagementPoint" - ], - "forbiddenStrengths": [ - "exactCorroborated" - ], - "forbiddenConfidences": [ - "high" + "policyManagementPoint", + "updatesSoftwareUpdatePoint" ], + "forbiddenStrengths": [], + "forbiddenConfidences": [], "requiredOutputs": [ "publicSafeHandles", "redactedProjection" @@ -124,14 +127,11 @@ "guardId": "reordered-input", "appliesTo": [ "contentDistributionPoint", - "policyManagementPoint" - ], - "forbiddenStrengths": [ - "exactCorroborated" - ], - "forbiddenConfidences": [ - "high" + "policyManagementPoint", + "updatesSoftwareUpdatePoint" ], + "forbiddenStrengths": [], + "forbiddenConfidences": [], "requiredOutputs": [ "deterministicSerialization", "sourceLocalResults" @@ -141,7 +141,8 @@ "guardId": "rotation-split", "appliesTo": [ "contentDistributionPoint", - "policyManagementPoint" + "policyManagementPoint", + "updatesSoftwareUpdatePoint" ], "forbiddenStrengths": [ "exactCorroborated" @@ -158,7 +159,8 @@ "guardId": "same-time-no-key", "appliesTo": [ "contentDistributionPoint", - "policyManagementPoint" + "policyManagementPoint", + "updatesSoftwareUpdatePoint" ], "forbiddenStrengths": [ "exactCorroborated" @@ -175,7 +177,8 @@ "guardId": "unknown-extraction-profile", "appliesTo": [ "contentDistributionPoint", - "policyManagementPoint" + "policyManagementPoint", + "updatesSoftwareUpdatePoint" ], "forbiddenStrengths": [ "exactCorroborated" @@ -192,7 +195,8 @@ "guardId": "unrelated-terminal-error", "appliesTo": [ "contentDistributionPoint", - "policyManagementPoint" + "policyManagementPoint", + "updatesSoftwareUpdatePoint" ], "forbiddenStrengths": [ "exactCorroborated" @@ -209,7 +213,8 @@ "guardId": "version-mismatch", "appliesTo": [ "contentDistributionPoint", - "policyManagementPoint" + "policyManagementPoint", + "updatesSoftwareUpdatePoint" ], "forbiddenStrengths": [ "exactCorroborated" 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/sccm_correlation_contract.rs b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs index 5406d5f29..e371dd4ba 100644 --- a/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_correlation_contract.rs @@ -1,12 +1,19 @@ -use std::collections::{BTreeMap, BTreeSet}; +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; 2] = ["contentDistributionPoint", "policyManagementPoint"]; +const WORKFLOWS: [&str; 3] = [ + "contentDistributionPoint", + "policyManagementPoint", + "updatesSoftwareUpdatePoint", +]; const GUARD_IDS: [&str; 13] = [ "conflicting-exact-key", "incompatible-topology", @@ -22,90 +29,45 @@ const GUARD_IDS: [&str; 13] = [ "unrelated-terminal-error", "version-mismatch", ]; -/// Closed obligation vocabulary: each guard's requiredOutputs are pinned -/// here and every token has an executable predicate over the scenario's -/// expected contract in `output_obligation_holds`. -const GUARD_REQUIRED_OUTPUTS: [(&str, &[&str]); 13] = [ - ( - "conflicting-exact-key", - &["incompatibilityReason", "sourceLocalResults"], - ), - ( - "incompatible-topology", - &["incompatibilityReason", "sourceLocalResults"], - ), - ( - "invalid-timestamp-offset", - &["orderingUnavailable", "sourceLocalResults"], - ), +const MATRIX_PATHS: [(&str, &str, &str); 3] = [ ( - "missing-client-counterpart", - &["clientArtifactRequest", "serverLocalResults"], - ), - ( - "missing-server-counterpart", - &["clientLocalResults", "serverArtifactRequest"], - ), - ("partial-capture", &["coverageGap", "sourceLocalResults"]), - ( - "redaction-boundary", - &["publicSafeHandles", "redactedProjection"], - ), - ( - "reordered-input", - &["deterministicSerialization", "sourceLocalResults"], - ), - ("rotation-split", &["coverageGap", "sourceLocalResults"]), - ( - "same-time-no-key", - &["candidateSymptom", "sourceLocalResults"], - ), - ( - "unknown-extraction-profile", - &["profileGap", "sourceLocalResults"], + "content_distribution_point/adversarial-matrix.json", + "contentDistributionPoint", + "content", ), ( - "unrelated-terminal-error", - &["sourceLocalResults", "unlinkedTerminalEvidence"], + "policy_management_point/adversarial-matrix.json", + "policyManagementPoint", + "policy", ), ( - "version-mismatch", - &["incompatibilityReason", "sourceLocalResults"], + "updates_software_update_point/adversarial-matrix.json", + "updatesSoftwareUpdatePoint", + "updates", ), ]; -const POLICY_SCENARIOS: [&str; 14] = [ - "policy-client-only", - "policy-conflicting-key", - "policy-invalid-offset", - "policy-partial-capture", - "policy-redaction", - "policy-reordered-input-a", - "policy-reordered-input-b", - "policy-rotation-split", - "policy-same-time-no-key", - "policy-server-only", - "policy-topology-mismatch", - "policy-unknown-profile", - "policy-unrelated-terminal-error", - "policy-version-mismatch", -]; -const CONTENT_SCENARIOS: [&str; 14] = [ - "content-client-only", - "content-conflicting-key", - "content-invalid-offset", - "content-partial-capture", - "content-redaction", - "content-reordered-input-a", - "content-reordered-input-b", - "content-rotation-split", - "content-same-time-no-key", - "content-server-only", - "content-topology-mismatch", - "content-unknown-profile", - "content-unrelated-terminal-error", - "content-version-mismatch", -]; +#[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)] @@ -126,224 +88,34 @@ struct GuardContract { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ScenarioMatrix { +struct OracleMatrix { schema_version: String, - workflow: String, - scenarios: Vec, + pair: String, + scenarios: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ScenarioContract { +struct OracleScenario { scenario_id: String, - guard_ids: Vec, - client_issue: String, - server_issue: String, - client_fixture_ref: String, - server_fixture_ref: String, - #[serde(default)] - ordered_input_evidence: Vec, - profile_state: ProfileState, - key_relation: KeyRelation, - topology: TopologyState, - timestamp_provenance: TimestampState, - coverage: CoverageState, - rotation: RotationState, - terminal_relation: TerminalRelation, - private_input_markers: Vec, - expected_public_projection: Value, - expected: ExpectedCeiling, -} - -#[derive(Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -enum ProfileState { - Validated, - Unknown, - VersionMismatch, -} - -#[derive(Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -enum KeyRelation { - Exact, - Conflicting, - Missing, - VersionMismatch, + mutation: String, + expected_outcome: String, + expected_link_strength: String, + expected_confidence: String, + expected_reason_codes: Vec, + expected_triggered_guards: Vec, + expected_output_sha256: String, } -#[derive(Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -enum TopologyState { - Compatible, - Incomplete, - Incompatible, -} - -#[derive(Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -enum TimestampState { - Usable, - Missing, - InvalidOffset, -} - -#[derive(Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -enum CoverageState { - Complete, - ClientOnly, - ServerOnly, - Partial, -} - -#[derive(Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -enum RotationState { - Complete, - Split, -} - -#[derive(Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -enum TerminalRelation { - Corroborating, - Missing, - Contradictory, - Unrelated, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ExpectedCeiling { - link_strength_ceiling: String, - confidence_ceiling: String, - high_confidence_cause_allowed: bool, - exact_corroborated_allowed: bool, - source_findings_mutable: bool, - reason_codes: Vec, - artifact_requests: Vec, - deterministic_result_id: String, -} - -#[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: PairState, - production_enabled: bool, - rule_validated: bool, - implementation_module: Option, - required_guard_ids: Vec, - blockers: Vec, -} - -#[derive(Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -enum PairState { - ContractPrepared, - Candidate, -} - -/// Upstream acceptance state of one side of a correlation pair. -/// -/// `Merged` sides may only cite already merged synthetic corpus directories -/// under their own side prefix (or pair-local `synthetic:` inputs). `Pending` -/// sides may have a merged preparation corpus, but their public fact -/// interface is not independently accepted, so they must stay honestly -/// marked with `issue:` refs until that acceptance lands. -enum SideCorpus { - Merged { repo_prefix: &'static str }, - Pending { issue: &'static str }, -} - -struct MatrixSpec { - workflow: &'static str, - scenario_ids: &'static [&'static str], - client_issue: &'static str, - server_issue: &'static str, - client_side: SideCorpus, - server_side: SideCorpus, -} - -const POLICY_SPEC: MatrixSpec = MatrixSpec { - workflow: "policyManagementPoint", - scenario_ids: &POLICY_SCENARIOS, - client_issue: "#321", - server_issue: "#328", - client_side: SideCorpus::Merged { - repo_prefix: "crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/", - }, - server_side: SideCorpus::Merged { - repo_prefix: "crates/cmtraceopen-parser/tests/fixtures/sccm/server/management-point/", - }, -}; - -const CONTENT_SPEC: MatrixSpec = MatrixSpec { - workflow: "contentDistributionPoint", - scenario_ids: &CONTENT_SCENARIOS, - client_issue: "#322", - server_issue: "#329", - client_side: SideCorpus::Merged { - repo_prefix: "crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/", - }, - // #329's preparation corpus is merged, but its public fact interface is - // not independently accepted (no production reducer exists for the - // #322/#329 pair), so the server side stays issue-marked and must not - // present the corpus as accepted upstream facts. - server_side: SideCorpus::Pending { issue: "#329" }, -}; - -const PAIR_OWNERSHIP: [(&str, &str, &str, &str); 3] = [ - ( - "content-distribution-point", - "contentDistributionPoint", - "#322", - "#329", - ), - ( - "policy-management-point", - "policyManagementPoint", - "#321", - "#328", - ), - ( - "updates-software-update-point", - "updatesSoftwareUpdatePoint", - "#323", - "#330", - ), -]; - -const CONTENT_PENDING_BLOCKER: &str = "#329 public fact interface not independently accepted"; - fn corpus_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/correlation") } -fn repo_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("parser crate has a repository root") - .to_path_buf() -} - 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!("{} is JSON: {error}", path.display())) + 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 { @@ -351,1029 +123,332 @@ fn typed Deserialize<'de>>(value: Value) -> Result { .map_err(|error| format!("fixture is not typed contract JSON: {error}")) } -fn is_sorted_unique(values: &[String]) -> bool { +fn sorted_unique(values: &[String]) -> bool { values.windows(2).all(|pair| pair[0] < pair[1]) } -fn validate_issue(value: &str) -> bool { +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 is_synthetic_slug(value: &str) -> bool { - !value.is_empty() +fn valid_hash(value: &str) -> bool { + value.len() == 64 && value .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) } -fn check_fixture_ref(value: &str, side: &SideCorpus, side_name: &str) -> Result<(), String> { - if value == "absent" { - return Ok(()); +fn check_registry(registry: &PairRegistry) -> Result<(), String> { + if registry.schema_version != CONTRACT_SCHEMA_VERSION { + return Err("registry schema version changed".to_owned()); } - if let Some(path) = value.strip_prefix("repo:") { - let SideCorpus::Merged { repo_prefix } = side else { - return Err(format!( - "{side_name} fixture ref {value} cites a merged corpus while the upstream side is pending" - )); - }; - if !path.starts_with(repo_prefix) { - return Err(format!( - "{side_name} fixture ref {value} is outside the side corpus {repo_prefix}" - )); - } - if path.contains("..") || !repo_root().join(path).is_dir() { - return Err(format!( - "{side_name} fixture ref {value} does not name a merged corpus directory" - )); - } - return Ok(()); + 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()); } - if let Some(synthetic_id) = value.strip_prefix("synthetic:") { - if !matches!(side, SideCorpus::Merged { .. }) { - return Err(format!( - "{side_name} fixture ref {value} hides a pending upstream side behind a synthetic input" - )); - } - if !is_synthetic_slug(synthetic_id) { - return Err(format!( - "{side_name} fixture ref {value} has a malformed synthetic slug" - )); + 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)); } - return Ok(()); - } - if let Some(pending) = value.strip_prefix("issue:") { - let SideCorpus::Pending { issue } = side else { - return Err(format!( - "{side_name} fixture ref {value} claims a pending upstream while the side corpus is merged" - )); - }; - let valid = pending - .strip_prefix(issue) - .and_then(|rest| rest.strip_prefix(':')) - .is_some_and(is_synthetic_slug); - if !valid { - return Err(format!( - "{side_name} fixture ref {value} is not a pending {issue} scenario" - )); + if !valid_issue(&pair.client_issue) || !valid_issue(&pair.server_issue) { + return Err(format!("{}: malformed issue ownership", pair.pair_id)); } - return Ok(()); - } - Err(format!("{side_name} fixture ref {value} has no known form")) -} - -fn collect_decoded_strings(value: &Value, sink: &mut Vec) { - match value { - Value::String(text) => sink.push(text.clone()), - Value::Array(items) => { - for item in items { - collect_decoded_strings(item, sink); - } + 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)); } - Value::Object(entries) => { - for (key, item) in entries { - sink.push(key.clone()); - collect_decoded_strings(item, sink); - } + if pair.required_guard_ids != GUARD_IDS { + return Err(format!("{}: guard registry changed", pair.pair_id)); } - Value::Null | Value::Bool(_) | Value::Number(_) => {} } + Ok(()) } -fn projection_string_is_safe(value: &str) -> bool { - !value.is_empty() - && value.len() <= 96 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.')) -} - -fn check_scenario(scenario: &ScenarioContract, spec: &MatrixSpec) -> Result<(), String> { - let scenario_id = scenario.scenario_id.as_str(); - if scenario.client_issue != spec.client_issue { - return Err(format!("{scenario_id}: unexpected client issue")); - } - if scenario.server_issue != spec.server_issue { - return Err(format!("{scenario_id}: unexpected server issue")); - } - if !validate_issue(&scenario.client_issue) || !validate_issue(&scenario.server_issue) { - return Err(format!("{scenario_id}: malformed issue reference")); - } - check_fixture_ref(&scenario.client_fixture_ref, &spec.client_side, "client") - .map_err(|error| format!("{scenario_id}: {error}"))?; - check_fixture_ref(&scenario.server_fixture_ref, &spec.server_side, "server") - .map_err(|error| format!("{scenario_id}: {error}"))?; - if !scenario.ordered_input_evidence.is_empty() { - if scenario.ordered_input_evidence.len() < 2 { - return Err(format!( - "{scenario_id}: reordered input evidence needs at least two entries" - )); - } - for entry in &scenario.ordered_input_evidence { - let side_tagged = entry - .strip_prefix("client:") - .or_else(|| entry.strip_prefix("server:")) - .is_some_and(is_synthetic_slug); - if !side_tagged { - return Err(format!( - "{scenario_id}: reordered evidence entry {entry} is not a side-tagged synthetic token" - )); - } - } - for side in ["client:", "server:"] { - if !scenario - .ordered_input_evidence - .iter() - .any(|entry| entry.starts_with(side)) - { - return Err(format!( - "{scenario_id}: reordered evidence must include the {side} side" - )); - } - } - } - if scenario.guard_ids.is_empty() || !is_sorted_unique(&scenario.guard_ids) { - return Err(format!( - "{scenario_id}: guard IDs must be nonempty, sorted, and unique" - )); +fn check_guard_matrix(matrix: &GuardMatrix) -> Result<(), String> { + if matrix.schema_version != CONTRACT_SCHEMA_VERSION { + return Err("guard schema version changed".to_owned()); } - if !scenario - .guard_ids + if matrix + .guards .iter() - .all(|guard| GUARD_IDS.contains(&guard.as_str())) - { - return Err(format!("{scenario_id}: unknown guard")); - } - if scenario.expected.high_confidence_cause_allowed { - return Err(format!( - "{scenario_id}: adversarial fixture cannot permit high-confidence cause" - )); - } - if scenario.expected.exact_corroborated_allowed { - return Err(format!( - "{scenario_id}: adversarial fixture cannot permit ExactCorroborated" - )); - } - if scenario.expected.source_findings_mutable { - return Err(format!( - "{scenario_id}: source findings must stay immutable" - )); - } - if scenario.expected.link_strength_ceiling == "exactCorroborated" { - return Err(format!( - "{scenario_id}: link ceiling reached exactCorroborated" - )); - } - if scenario.expected.confidence_ceiling == "high" { - return Err(format!("{scenario_id}: confidence ceiling reached high")); - } - if !["candidate", "exactPartial", "incompatible", "unlinked"] - .contains(&scenario.expected.link_strength_ceiling.as_str()) - { - return Err(format!("{scenario_id}: unknown link strength ceiling")); - } - if !["low", "medium"].contains(&scenario.expected.confidence_ceiling.as_str()) { - return Err(format!("{scenario_id}: unknown confidence ceiling")); - } - if scenario.expected.reason_codes.is_empty() - || !is_sorted_unique(&scenario.expected.reason_codes) - { - return Err(format!( - "{scenario_id}: reason codes must be nonempty, sorted, and unique" - )); - } - if !is_sorted_unique(&scenario.expected.artifact_requests) { - return Err(format!( - "{scenario_id}: artifact requests must be sorted and unique" - )); - } - if scenario.expected.deterministic_result_id.is_empty() { - return Err(format!("{scenario_id}: deterministic result id is empty")); - } - - let mut decoded_strings = Vec::new(); - collect_decoded_strings(&scenario.expected_public_projection, &mut decoded_strings); - for text in &decoded_strings { - if !projection_string_is_safe(text) { - return Err(format!( - "{scenario_id}: decoded projection string {text:?} is outside the closed public grammar" - )); - } - } - for marker in &scenario.private_input_markers { - if marker.is_empty() { - return Err(format!("{scenario_id}: private marker is empty")); - } - if decoded_strings.iter().any(|text| text.contains(marker)) { - return Err(format!( - "{scenario_id}: private marker leaked into a decoded projection value" - )); - } - } - if scenario.profile_state != ProfileState::Validated - && !scenario - .guard_ids - .iter() - .any(|guard| guard == "unknown-extraction-profile" || guard == "version-mismatch") - { - return Err(format!("{scenario_id}: unvalidated profile lacks a guard")); - } - if scenario.key_relation == KeyRelation::Missing - && !scenario.guard_ids.contains(&"same-time-no-key".to_owned()) - { - return Err(format!( - "{scenario_id}: missing key lacks the same-time guard" - )); - } - if scenario.key_relation == KeyRelation::VersionMismatch - && !scenario.guard_ids.contains(&"version-mismatch".to_owned()) - { - return Err(format!( - "{scenario_id}: key version mismatch lacks its guard" - )); - } - if scenario.coverage == CoverageState::ClientOnly - && scenario.expected.artifact_requests.is_empty() - { - return Err(format!( - "{scenario_id}: client-only coverage must request server artifacts" - )); - } - if scenario.coverage == CoverageState::ServerOnly - && scenario.expected.artifact_requests.is_empty() - { - return Err(format!( - "{scenario_id}: server-only coverage must request client artifacts" - )); - } - - if (scenario.client_fixture_ref == "absent") != (scenario.coverage == CoverageState::ServerOnly) - { - return Err(format!( - "{scenario_id}: an absent client fixture ref must match server-only coverage" - )); - } - if (scenario.server_fixture_ref == "absent") != (scenario.coverage == CoverageState::ClientOnly) + .map(|guard| guard.guard_id.as_str()) + .collect::>() + != GUARD_IDS { - return Err(format!( - "{scenario_id}: an absent server fixture ref must match client-only coverage" - )); + return Err("shared guard membership changed".to_owned()); } - for guard in GUARD_IDS { - let declared = scenario.guard_ids.iter().any(|declared| declared == guard); - let demonstrated = guard_demonstrated(guard, scenario); - if declared && !demonstrated { - return Err(format!( - "{scenario_id}: guard {guard} is declared but not demonstrated by the inputs" - )); - } - if demonstrated && !declared { + for guard in &matrix.guards { + if guard.applies_to != WORKFLOWS { return Err(format!( - "{scenario_id}: inputs demonstrate guard {guard} which is not declared" + "{}: not applied to all three pairs", + guard.guard_id )); } - } - for guard in &scenario.guard_ids { - let (_, outputs) = GUARD_REQUIRED_OUTPUTS - .iter() - .find(|(pinned_id, _)| pinned_id == guard) - .ok_or_else(|| { - format!("{scenario_id}: guard {guard} has no pinned output obligations") - })?; - for output in *outputs { - if !output_obligation_holds(output, scenario) { - return Err(format!( - "{scenario_id}: guard {guard} obligation {output} is not satisfied by the expected contract" - )); - } + 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(()) } -/// Evaluates one required-output token against the scenario's expected -/// results and public projection, so obligations are executable -/// constraints instead of declarative strings. -fn output_obligation_holds(output: &str, scenario: &ScenarioContract) -> bool { - let projection = &scenario.expected_public_projection; - match output { - "incompatibilityReason" => { - projection["outcome"] == "incompatible" - && scenario.expected.link_strength_ceiling == "incompatible" - } - "sourceLocalResults" => projection["sourceFindingsPreserved"] == true, - "clientLocalResults" => { - projection["sourceFindingsPreserved"] == true - && scenario.coverage == CoverageState::ClientOnly - } - "serverLocalResults" => { - projection["sourceFindingsPreserved"] == true - && scenario.coverage == CoverageState::ServerOnly - } - "clientArtifactRequest" => { - projection["outcome"] == "counterpartRequested" - && scenario - .expected - .artifact_requests - .iter() - .any(|request| request.starts_with("client-")) - } - "serverArtifactRequest" => { - projection["outcome"] == "counterpartRequested" - && scenario - .expected - .artifact_requests - .iter() - .any(|request| request.starts_with("server-")) - } - "orderingUnavailable" => projection["ordering"] == "unavailable", - "coverageGap" => projection["outcome"] == "coverageGap", - "candidateSymptom" => { - projection["outcome"] == "candidateOnly" - && scenario.expected.link_strength_ceiling == "candidate" - } - "profileGap" => projection["outcome"] == "profileGap", - "unlinkedTerminalEvidence" => scenario - .expected - .reason_codes - .iter() - .any(|code| code == "unrelated-server-terminal"), - "publicSafeHandles" => projection.as_object().is_some_and(|entries| { - entries - .iter() - .filter(|(key, value)| { - key.ends_with("Handle") && value.as_str().is_some_and(is_synthetic_slug) - }) - .count() - >= 2 - }), - "redactedProjection" => !scenario.private_input_markers.is_empty(), - "deterministicSerialization" => { - !scenario.ordered_input_evidence.is_empty() - && !scenario.expected.deterministic_result_id.is_empty() - } - _ => false, +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}"), } } -/// True when the scenario's own input state instantiates the guard's -/// adversarial construction. Every declared guard must be demonstrated by -/// the inputs, and every demonstrated guard must be declared, so a guard -/// label can never outlive a neutralized input. -fn guard_demonstrated(guard: &str, scenario: &ScenarioContract) -> bool { - match guard { - "conflicting-exact-key" => scenario.key_relation == KeyRelation::Conflicting, - "incompatible-topology" => scenario.topology == TopologyState::Incompatible, - "invalid-timestamp-offset" => { - scenario.timestamp_provenance == TimestampState::InvalidOffset - } - "missing-client-counterpart" => { - scenario.coverage == CoverageState::ServerOnly - && scenario.client_fixture_ref == "absent" - && scenario.server_fixture_ref != "absent" - } - "missing-server-counterpart" => { - scenario.coverage == CoverageState::ClientOnly - && scenario.server_fixture_ref == "absent" - && scenario.client_fixture_ref != "absent" - } - "partial-capture" => scenario.coverage == CoverageState::Partial, - "redaction-boundary" => !scenario.private_input_markers.is_empty(), - "reordered-input" => !scenario.ordered_input_evidence.is_empty(), - "rotation-split" => scenario.rotation == RotationState::Split, - "same-time-no-key" => { - scenario.key_relation == KeyRelation::Missing - && scenario.timestamp_provenance == TimestampState::Usable - } - "unknown-extraction-profile" => scenario.profile_state == ProfileState::Unknown, - "unrelated-terminal-error" => scenario.terminal_relation == TerminalRelation::Unrelated, - "version-mismatch" => { - scenario.profile_state == ProfileState::VersionMismatch - && scenario.key_relation == KeyRelation::VersionMismatch - } - _ => false, +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")); } -} - -fn check_matrix_contract(matrix: &ScenarioMatrix, spec: &MatrixSpec) -> Result<(), String> { - if matrix.schema_version != CONTRACT_SCHEMA_VERSION { + if matrix.scenarios.len() != 15 { return Err(format!( - "unexpected schema version {}", - matrix.schema_version + "{workflow}: expected healthy plus 14 adversarial cases" )); } - if matrix.workflow != spec.workflow { - return Err(format!("unexpected workflow {}", matrix.workflow)); - } - let scenario_ids = matrix - .scenarios - .iter() - .map(|scenario| scenario.scenario_id.as_str()) - .collect::>(); - if scenario_ids != spec.scenario_ids { - return Err(format!("{}: scenario matrix changed", spec.workflow)); - } - - let mut exercised_guards = BTreeSet::new(); + let mut scenario_ids = BTreeSet::new(); + let mut guards = BTreeSet::new(); + let mut reordered_hashes = Vec::new(); for scenario in &matrix.scenarios { - check_scenario(scenario, spec)?; - exercised_guards.extend(scenario.guard_ids.iter().map(String::as_str)); - } - if exercised_guards != GUARD_IDS.into_iter().collect() { - return Err(format!( - "{}: every shared guard needs a pair-specific adversarial scenario", - spec.workflow - )); - } - check_reordered_pair(matrix, spec)?; - Ok(()) -} - -/// The reordered A/B cases must feed one identical input set through two -/// opposite evidence orders and still pin one deterministic contract. -fn check_reordered_pair(matrix: &ScenarioMatrix, spec: &MatrixSpec) -> Result<(), String> { - let reordered = matrix - .scenarios - .iter() - .filter(|scenario| scenario.guard_ids.contains(&"reordered-input".to_owned())) - .collect::>(); - let [first, second] = reordered.as_slice() else { - return Err(format!( - "{}: exactly two reordered-input scenarios are required", - spec.workflow - )); - }; - if first.client_fixture_ref != second.client_fixture_ref - || first.server_fixture_ref != second.server_fixture_ref - { - return Err(format!( - "{}: reordered scenarios must share identical fixture refs", - spec.workflow - )); - } - if first.profile_state != second.profile_state - || first.key_relation != second.key_relation - || first.topology != second.topology - || first.timestamp_provenance != second.timestamp_provenance - || first.coverage != second.coverage - || first.rotation != second.rotation - || first.terminal_relation != second.terminal_relation - { - return Err(format!( - "{}: reordered scenarios must share identical input state", - spec.workflow - )); - } - if second.ordered_input_evidence == first.ordered_input_evidence { - return Err(format!( - "{}: reordered cases must not present the same order twice", - spec.workflow - )); - } - let reversed = first - .ordered_input_evidence - .iter() - .rev() - .cloned() - .collect::>(); - if second.ordered_input_evidence != reversed { - return Err(format!( - "{}: reordered case B must replay case A's evidence in opposite order", - spec.workflow - )); - } - if first.expected != second.expected { - return Err(format!( - "{}: reordered scenarios must pin one expected result", - spec.workflow - )); - } - let projection_a = serde_json::to_string(&first.expected_public_projection) - .expect("expected public projection serializes"); - let projection_b = serde_json::to_string(&second.expected_public_projection) - .expect("expected public projection serializes"); - if projection_a != projection_b { - return Err(format!( - "{}: reordered scenarios must serialize one deterministic public projection", - spec.workflow - )); - } - Ok(()) -} - -fn check_guard_matrix(matrix: &GuardMatrix) -> Result<(), String> { - if matrix.schema_version != CONTRACT_SCHEMA_VERSION { - return Err(format!( - "unexpected schema version {}", - matrix.schema_version - )); - } - let guard_ids = matrix - .guards - .iter() - .map(|guard| guard.guard_id.as_str()) - .collect::>(); - if guard_ids != GUARD_IDS { - return Err("shared guard list changed".to_owned()); - } - - for guard in &matrix.guards { - let guard_id = guard.guard_id.as_str(); - if guard.applies_to != WORKFLOWS { - return Err(format!("{guard_id}: guard must apply to both first pairs")); - } - if guard.forbidden_strengths != ["exactCorroborated"] { - return Err(format!("{guard_id}: guard must forbid exactCorroborated")); - } - if guard.forbidden_confidences != ["high"] { - return Err(format!("{guard_id}: guard must forbid high confidence")); - } - let (_, pinned_outputs) = GUARD_REQUIRED_OUTPUTS - .iter() - .find(|(pinned_id, _)| *pinned_id == guard_id) - .ok_or_else(|| format!("{guard_id}: guard has no pinned output obligations"))?; - if guard.required_outputs != *pinned_outputs { + if !scenario.scenario_id.starts_with(&format!("{prefix}-")) + || !scenario_ids.insert(scenario.scenario_id.as_str()) + { return Err(format!( - "{guard_id}: required outputs must stay the pinned executable obligations" + "{}: malformed or duplicate scenario ID", + scenario.scenario_id )); } - } - Ok(()) -} - -fn check_pair_registry(registry: &PairRegistry) -> Result<(), String> { - if registry.schema_version != CONTRACT_SCHEMA_VERSION { - return Err(format!( - "unexpected schema version {}", - registry.schema_version - )); - } - let pair_ids = registry - .pairs - .iter() - .map(|pair| pair.pair_id.as_str()) - .collect::>(); - if pair_ids - != [ - "content-distribution-point", - "policy-management-point", - "updates-software-update-point", - ] - { - return Err("pair registry membership changed".to_owned()); - } - - let mut workflow_states = BTreeMap::new(); - for pair in ®istry.pairs { - let pair_id = pair.pair_id.as_str(); - if !validate_issue(&pair.client_issue) || !validate_issue(&pair.server_issue) { - return Err(format!("{pair_id}: malformed issue reference")); - } - let (_, workflow, client_issue, server_issue) = PAIR_OWNERSHIP - .iter() - .find(|(owner_id, _, _, _)| *owner_id == pair_id) - .ok_or_else(|| format!("{pair_id}: pair has no pinned ownership"))?; - if pair.workflow != *workflow { - return Err(format!("{pair_id}: workflow ownership changed")); - } - if pair.client_issue != *client_issue || pair.server_issue != *server_issue { + if !valid_hash(&scenario.expected_output_sha256) { return Err(format!( - "{pair_id}: issue ownership must stay {client_issue} to {server_issue}" + "{}: output hash is not exact SHA-256", + scenario.scenario_id )); } - if pair_id == "content-distribution-point" - && !pair - .blockers - .iter() - .any(|blocker| blocker == CONTENT_PENDING_BLOCKER) + if !sorted_unique(&scenario.expected_reason_codes) + || !sorted_unique(&scenario.expected_triggered_guards) { return Err(format!( - "{pair_id}: the #329 pending acceptance blocker must stay declared" + "{}: expected lists are not deterministic", + scenario.scenario_id )); } - if pair.production_enabled { - return Err(format!("{pair_id}: production must stay disabled")); + 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 pair.rule_validated { - return Err(format!("{pair_id}: no pair may claim RuleValidated")); + if let Some(guard) = mutation_guard(&scenario.mutation) { + guards.insert(guard); } - if pair.implementation_module.is_some() { - return Err(format!("{pair_id}: no implementation module is permitted")); + if scenario.mutation.starts_with("reorderedInput") { + reordered_hashes.push(scenario.expected_output_sha256.as_str()); } - if pair.required_guard_ids != GUARD_IDS { - return Err(format!("{pair_id}: required guard list changed")); + if scenario.expected_link_strength == "exactCorroborated" + && scenario.expected_confidence != "high" + { + return Err(format!( + "{}: exact link is not high confidence", + scenario.scenario_id + )); } - if pair.blockers.is_empty() || !is_sorted_unique(&pair.blockers) { + if scenario.expected_confidence == "high" + && scenario.expected_link_strength != "exactCorroborated" + { return Err(format!( - "{pair_id}: blockers must be nonempty, sorted, and unique" + "{}: high confidence escaped exact linking", + scenario.scenario_id )); } - workflow_states.insert(pair.workflow.clone(), &pair.state); - } - if workflow_states - .keys() - .map(String::as_str) - .collect::>() - != [ - "contentDistributionPoint", - "policyManagementPoint", - "updatesSoftwareUpdatePoint", - ] - .into_iter() - .collect() - { - return Err("pair registry workflows changed".to_owned()); - } - if *workflow_states["contentDistributionPoint"] != PairState::ContractPrepared { - return Err("content pair left ContractPrepared".to_owned()); } - if *workflow_states["policyManagementPoint"] != PairState::ContractPrepared { - return Err("policy pair left ContractPrepared".to_owned()); + if guards != GUARD_IDS.into_iter().collect() { + return Err(format!( + "{workflow}: not every shared guard has an executable scenario" + )); } - if *workflow_states["updatesSoftwareUpdatePoint"] != PairState::Candidate { - return Err("updates pair must stay Candidate".to_owned()); + if reordered_hashes.len() != 2 || reordered_hashes[0] != reordered_hashes[1] { + return Err(format!( + "{workflow}: reordered inputs do not pin identical full output" + )); } Ok(()) } -const POLICY_MATRIX_FIXTURE: &str = "policy_management_point/adversarial-matrix.json"; -const CONTENT_MATRIX_FIXTURE: &str = "content_distribution_point/adversarial-matrix.json"; +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( - fixture: &str, - spec: &MatrixSpec, + path: &str, + workflow: &str, + prefix: &str, mutate: impl FnOnce(&mut Value), ) -> Result<(), String> { - let mut value = read_json(&corpus_root().join(fixture)); + let mut value = read_json(&corpus_root().join(path)); mutate(&mut value); - let matrix: ScenarioMatrix = typed(value)?; - check_matrix_contract(&matrix, spec) -} - -fn scenario_slot<'a>(matrix: &'a mut Value, scenario_id: &str) -> &'a mut Value { - matrix["scenarios"] - .as_array_mut() - .expect("scenario matrix fixture has a scenarios array") - .iter_mut() - .find(|scenario| scenario["scenarioId"] == scenario_id) - .unwrap_or_else(|| panic!("scenario {scenario_id} exists in the fixture")) + check_matrix(&typed(value)?, workflow, prefix) } -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); - let registry: PairRegistry = typed(value)?; - check_pair_registry(®istry) -} - -fn pair_slot<'a>(registry: &'a mut Value, pair_id: &str) -> &'a mut Value { - registry["pairs"] - .as_array_mut() - .expect("pair registry fixture has a pairs array") - .iter_mut() - .find(|pair| pair["pairId"] == pair_id) - .unwrap_or_else(|| panic!("pair {pair_id} exists in the registry")) +#[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 adversarial_fixture_ref_and_ownership_mutations_fail_closed() { - let error = check_mutated_matrix(CONTENT_MATRIX_FIXTURE, &CONTENT_SPEC, |matrix| { - scenario_slot(matrix, "content-invalid-offset")["serverFixtureRef"] = Value::String( - "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/deployment/success" - .to_owned(), - ); - }) - .expect_err("the pending #329 server corpus cannot be replaced by a merged client corpus"); - assert!(error.contains("content-invalid-offset"), "{error}"); - - let error = check_mutated_matrix(CONTENT_MATRIX_FIXTURE, &CONTENT_SPEC, |matrix| { - scenario_slot(matrix, "content-conflicting-key")["serverFixtureRef"] = - Value::String("synthetic:content-divergent-server".to_owned()); - }) - .expect_err("the pending #329 server side cannot dodge into a synthetic ref"); - assert!(error.contains("content-conflicting-key"), "{error}"); - - let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { - scenario_slot(matrix, "policy-conflicting-key")["serverFixtureRef"] = Value::String( - "repo:crates/cmtraceopen-parser/tests/fixtures/sccm/client/policy/complete".to_owned(), - ); - }) - .expect_err("a server fixture ref cannot cite a client-side corpus directory"); - assert!(error.contains("policy-conflicting-key"), "{error}"); - - let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { - scenario_slot(matrix, "policy-conflicting-key")["serverFixtureRef"] = - Value::String("issue:#328:healthy-policy".to_owned()); - }) - .expect_err("a merged upstream side cannot claim a pending issue ref"); - assert!(error.contains("policy-conflicting-key"), "{error}"); - - let error = check_mutated_registry(|registry| { - pair_slot(registry, "content-distribution-point")["serverIssue"] = - Value::String("#328".to_owned()); - }) - .expect_err("the content pair server issue is pinned to #329"); - assert!(error.contains("content-distribution-point"), "{error}"); - - let error = check_mutated_registry(|registry| { - pair_slot(registry, "content-distribution-point")["blockers"] = serde_json::json!([ - "#318 finding interface exact-head review pending", - "#322 public fact interface not implemented" - ]); - }) - .expect_err("the content pair must stay honest about #329 pending acceptance"); - assert!(error.contains("content-distribution-point"), "{error}"); +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 reordered_scenarios_encode_opposite_order_input_evidence() { - for (fixture, prefix) in [ - (POLICY_MATRIX_FIXTURE, "policy"), - (CONTENT_MATRIX_FIXTURE, "content"), - ] { - let mut matrix = read_json(&corpus_root().join(fixture)); - let first = scenario_slot(&mut matrix, &format!("{prefix}-reordered-input-a")).clone(); - let second = scenario_slot(&mut matrix, &format!("{prefix}-reordered-input-b")).clone(); - - let manifest = |scenario: &Value, label: &str| -> Vec { - scenario["orderedInputEvidence"] - .as_array() - .unwrap_or_else(|| panic!("{fixture}: scenario {label} encodes ordered inputs")) - .iter() - .map(|entry| { - entry - .as_str() - .unwrap_or_else(|| panic!("{fixture}: {label} evidence entry is a string")) - .to_owned() - }) - .collect() - }; - let evidence_a = manifest(&first, "a"); - let evidence_b = manifest(&second, "b"); - - assert!( - evidence_a.len() >= 2, - "{fixture}: too little ordered evidence" - ); - assert_ne!( - evidence_a, evidence_b, - "{fixture}: A and B are not reordered" - ); - let reversed_a = evidence_a.iter().rev().cloned().collect::>(); - assert_eq!( - evidence_b, reversed_a, - "{fixture}: B must replay A's evidence in opposite order" - ); - let mut multiset_a = evidence_a.clone(); - let mut multiset_b = evidence_b.clone(); - multiset_a.sort(); - multiset_b.sort(); - assert_eq!( - multiset_a, multiset_b, - "{fixture}: A and B must carry the same evidence multiset" - ); - for side in ["client:", "server:"] { - assert!( - evidence_a.iter().any(|entry| entry.starts_with(side)), - "{fixture}: ordered evidence must include the {side} side" - ); - } - - for field in [ - "clientFixtureRef", - "serverFixtureRef", - "profileState", - "keyRelation", - "topology", - "timestampProvenance", - "coverage", - "rotation", - "terminalRelation", - ] { - assert_eq!( - first[field], second[field], - "{fixture}: {field} must be identical between A and B" - ); - } - for section in ["expected", "expectedPublicProjection"] { - let serialized_a = serde_json::to_string(&first[section]) - .expect("reordered contract section serializes"); - let serialized_b = serde_json::to_string(&second[section]) - .expect("reordered contract section serializes"); - assert_eq!( - serialized_a, serialized_b, - "{fixture}: {section} must serialize deterministically across A and B" - ); - } - assert_eq!( - first["expected"]["deterministicResultId"], second["expected"]["deterministicResultId"], - "{fixture}: A and B must share one deterministic result id" - ); +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}")); } } -fn check_mutated_guard_matrix(mutate: impl FnOnce(&mut Value)) -> Result<(), String> { - let mut value = read_json(&corpus_root().join("shared/adversarial-matrix.json")); - mutate(&mut value); - let matrix: GuardMatrix = typed(value)?; - check_guard_matrix(&matrix) -} - -fn guard_slot<'a>(matrix: &'a mut Value, guard_id: &str) -> &'a mut Value { - matrix["guards"] - .as_array_mut() - .expect("guard matrix fixture has a guards array") - .iter_mut() - .find(|guard| guard["guardId"] == guard_id) - .unwrap_or_else(|| panic!("guard {guard_id} exists in the fixture")) -} - #[test] -fn adversarial_required_output_mutations_fail_closed() { - let error = check_mutated_guard_matrix(|matrix| { - guard_slot(matrix, "invalid-timestamp-offset")["requiredOutputs"] = - serde_json::json!(["arbitraryOutput"]); +fn production_registry_mutations_fail_closed() { + let error = check_mutated_registry(|registry| { + registry["pairs"][0]["productionEnabled"] = Value::Bool(false); }) - .expect_err("required outputs must stay pinned executable obligations, not free strings"); - assert!(error.contains("invalid-timestamp-offset"), "{error}"); + .expect_err("production disablement cannot remain admitted"); + assert!(error.contains("production admitted"), "{error}"); - let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { - scenario_slot(matrix, "policy-invalid-offset")["expectedPublicProjection"] = - serde_json::json!({ "outcome": "notCausal" }); + let error = check_mutated_registry(|registry| { + registry["pairs"][1]["implementationModule"] = Value::String("sccm::other".to_owned()); }) - .expect_err("dropping the ordering obligation from the projection must fail"); - assert!(error.contains("policy-invalid-offset"), "{error}"); + .expect_err("module ownership cannot drift"); + assert!(error.contains("production admitted"), "{error}"); - let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { - scenario_slot(matrix, "policy-client-only")["expected"]["artifactRequests"] = - serde_json::json!(["diagnostic-bundle"]); + let error = check_mutated_registry(|registry| { + registry["pairs"][2]["requiredGuardIds"] + .as_array_mut() + .expect("guard list") + .pop(); }) - .expect_err("a client-only scenario must request server-side artifacts"); - assert!(error.contains("policy-client-only"), "{error}"); + .expect_err("no pair may omit a shared guard"); + assert!(error.contains("guard registry"), "{error}"); } #[test] -fn adversarial_reordered_input_mutations_fail_closed() { - let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { - let order_a = - scenario_slot(matrix, "policy-reordered-input-a")["orderedInputEvidence"].clone(); - scenario_slot(matrix, "policy-reordered-input-b")["orderedInputEvidence"] = order_a; - }) - .expect_err("the B case must replay A's evidence in opposite order, not the same order"); - assert!(error.contains("reordered"), "{error}"); - - let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { - scenario_slot(matrix, "policy-reordered-input-b")["clientFixtureRef"] = - Value::String("synthetic:policy-divergent-client".to_owned()); - }) - .expect_err("the A/B pair must reorder one input set, not compare different inputs"); - assert!(error.contains("reordered"), "{error}"); - - let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { - scenario_slot(matrix, "policy-reordered-input-a")["orderedInputEvidence"] = - serde_json::json!([]); - }) - .expect_err("a reordered-input guard without encoded ordered evidence is undemonstrated"); - assert!(error.contains("reordered"), "{error}"); - - let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { - scenario_slot(matrix, "policy-reordered-input-a")["orderedInputEvidence"][0] = - Value::String("policy-request-assignments".to_owned()); +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("ordered evidence entries must be side-tagged synthetic tokens"); - assert!(error.contains("reordered"), "{error}"); -} - -#[test] -fn adversarial_neutralized_guard_state_mutations_fail_closed() { - let neutralizations: [(&str, &[(&str, &str)]); 9] = [ - ("policy-same-time-no-key", &[("keyRelation", "exact")]), - ("policy-conflicting-key", &[("keyRelation", "exact")]), - ("policy-topology-mismatch", &[("topology", "compatible")]), - ("policy-unknown-profile", &[("profileState", "validated")]), - ( - "policy-invalid-offset", - &[("timestampProvenance", "usable")], - ), - ("policy-rotation-split", &[("rotation", "complete")]), - ("policy-partial-capture", &[("coverage", "complete")]), - ( - "policy-unrelated-terminal-error", - &[("terminalRelation", "corroborating")], - ), - ( - "policy-version-mismatch", - &[("profileState", "validated"), ("keyRelation", "exact")], - ), - ]; - for (scenario_id, edits) in neutralizations { - let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { - let scenario = scenario_slot(matrix, scenario_id); - for (field, neutral) in edits { - scenario[*field] = Value::String((*neutral).to_owned()); - } - }) - .expect_err(&format!( - "{scenario_id}: neutralized inputs cannot keep the guard label green" + .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') )); - assert!(error.contains(scenario_id), "{error}"); - } - - let error = check_mutated_matrix(CONTENT_MATRIX_FIXTURE, &CONTENT_SPEC, |matrix| { - scenario_slot(matrix, "content-client-only")["coverage"] = - Value::String("complete".to_owned()); - }) - .expect_err("an absent server counterpart cannot claim complete coverage"); - assert!(error.contains("content-client-only"), "{error}"); -} - -#[test] -fn adversarial_projection_privacy_mutations_fail_closed() { - let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { - scenario_slot(matrix, "policy-redaction")["expectedPublicProjection"]["rawIdentity"] = - Value::String("LAB\\SyntheticUser".to_owned()); - }) - .expect_err("a decoded declared private marker cannot enter the public projection"); - assert!(error.contains("policy-redaction"), "{error}"); - - let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { - scenario_slot(matrix, "policy-invalid-offset")["expectedPublicProjection"] - ["evidencePath"] = Value::String("C:\\Windows\\CCM\\Logs\\PolicyAgent.log".to_owned()); - }) - .expect_err("an undeclared raw Windows path cannot enter the public projection"); - assert!(error.contains("policy-invalid-offset"), "{error}"); - - let error = check_mutated_matrix(POLICY_MATRIX_FIXTURE, &POLICY_SPEC, |matrix| { - scenario_slot(matrix, "policy-redaction")["expectedPublicProjection"] - ["LAB\\SyntheticUser"] = Value::Bool(true); }) - .expect_err("a decoded private marker cannot hide inside a projection key"); - assert!(error.contains("policy-redaction"), "{error}"); -} - -#[test] -fn correlation_preparation_contains_no_production_module() { - assert!( - !PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("src/sccm/correlation") - .exists(), - "#333 preparation must not add production correlation before upstream facts stabilize" - ); -} - -#[test] -fn shared_false_causality_guards_are_exact_and_pair_complete() { - let matrix: GuardMatrix = typed(read_json( - &corpus_root().join("shared/adversarial-matrix.json"), - )) - .unwrap_or_else(|error| panic!("{error}")); - check_guard_matrix(&matrix).unwrap_or_else(|error| panic!("{error}")); -} - -#[test] -fn policy_to_management_point_adversarial_matrix_is_conservative() { - let matrix: ScenarioMatrix = typed(read_json( - &corpus_root().join("policy_management_point/adversarial-matrix.json"), - )) - .unwrap_or_else(|error| panic!("{error}")); - check_matrix_contract(&matrix, &POLICY_SPEC).unwrap_or_else(|error| panic!("{error}")); -} - -#[test] -fn content_to_distribution_point_adversarial_matrix_is_conservative() { - let matrix: ScenarioMatrix = typed(read_json( - &corpus_root().join("content_distribution_point/adversarial-matrix.json"), - )) - .unwrap_or_else(|error| panic!("{error}")); - check_matrix_contract(&matrix, &CONTENT_SPEC).unwrap_or_else(|error| panic!("{error}")); -} - -#[test] -fn reordered_contracts_pin_identical_expected_results() { - for path in [ - "policy_management_point/adversarial-matrix.json", - "content_distribution_point/adversarial-matrix.json", - ] { - let matrix: ScenarioMatrix = - typed(read_json(&corpus_root().join(path))).unwrap_or_else(|error| panic!("{error}")); - let reordered = matrix - .scenarios - .iter() - .filter(|scenario| scenario.guard_ids.contains(&"reordered-input".to_owned())) - .collect::>(); - assert_eq!(reordered.len(), 2, "{path}"); - assert_eq!(reordered[0].expected, reordered[1].expected, "{path}"); - assert_eq!( - reordered[0].expected_public_projection, reordered[1].expected_public_projection, - "{path}" - ); - } + .expect_err("opposite orders must retain the same full-output hash"); + assert!(error.contains("reordered"), "{error}"); } #[test] -fn pair_registry_is_non_executable_and_expansion_is_gated() { - let registry: PairRegistry = typed(read_json(&corpus_root().join("pair-registry.json"))) - .unwrap_or_else(|error| panic!("{error}")); - check_pair_registry(®istry).unwrap_or_else(|error| panic!("{error}")); +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/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/library.md b/library.md index 9afee05b6..55913d2a0 100644 --- a/library.md +++ b/library.md @@ -1,3 +1,5 @@ # 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]] From 0fb7106f52942750b627491141dd6e6963c14276 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 05:34:19 -0400 Subject: [PATCH 408/422] test(sccm): reconcile integrated source contracts --- .../src/sccm/server/windows/intake.rs | 2 -- .../client/intake/access-denied/expected.json | 12 ++++++++ .../sccm/client/intake/capped/expected.json | 12 ++++++++ .../client/intake/collision/expected.json | 12 ++++++++ .../client/current/smsts.log | 1 + .../sccm/client/intake/complete/expected.json | 28 +++++++++++++++++++ .../sccm/client/intake/complete/manifest.json | 1 + .../client/intake/missing-root/expected.json | 12 ++++++++ .../client/intake/rotations/expected.json | 12 ++++++++ .../tests/sccm_client_deployment.rs | 2 ++ .../tests/sccm_client_intake.rs | 4 +-- 11 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/evidence/client-task-sequence-smsts/client/current/smsts.log diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index 455313fa7..e62f0c962 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -2702,8 +2702,6 @@ fn safe_manifest_artifact_id(value: &str, synthetic_fixture: bool) -> bool { | "provider-retry-current" | "provider-success-current" | "provider-timeout-current" - | "rotation-01-current" - | "rotation-02-lo" | "unknown-db-export" | "unrelated-02-wcm" | "unrelated-03-wsync" 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 index 7947ad36b..0ebdb91cc 100644 --- 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 @@ -71,6 +71,11 @@ "fixture-access-policy-agent-root-a-current" ] }, + { + "logicalArtifactId": "client-task-sequence-smsts", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-policy-state", "coverage": "captured", @@ -220,6 +225,13 @@ "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, diff --git a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json index d50d6aa8f..d375ab4fe 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/capped/expected.json @@ -71,6 +71,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-task-sequence-smsts", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-policy-state", "coverage": "absent", @@ -204,6 +209,13 @@ "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, 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 index 6de58d75c..ad2b58d8d 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/collision/expected.json @@ -72,6 +72,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-task-sequence-smsts", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-policy-state", "coverage": "absent", @@ -213,6 +218,13 @@ "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, 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/expected.json b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json index 44c38b57f..4d6bb6420 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/expected.json @@ -96,6 +96,13 @@ "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", @@ -168,6 +175,20 @@ "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", @@ -355,6 +376,7 @@ "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", @@ -392,6 +414,12 @@ "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, 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 index 1e0d02906..a42443a55 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/manifest.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/complete/manifest.json @@ -19,6 +19,7 @@ {"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"}, 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 index c0c70c2a9..d66f9fbed 100644 --- 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 @@ -88,6 +88,11 @@ "fixture-missing-policy-agent-current" ] }, + { + "logicalArtifactId": "client-task-sequence-smsts", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-policy-state", "coverage": "absent", @@ -372,6 +377,13 @@ "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", 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 index 501475dfd..97a4876eb 100644 --- a/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json +++ b/crates/cmtraceopen-parser/tests/fixtures/sccm/client/intake/rotations/expected.json @@ -73,6 +73,11 @@ "coverage": "absent", "fragmentArtifactIds": [] }, + { + "logicalArtifactId": "client-task-sequence-smsts", + "coverage": "absent", + "fragmentArtifactIds": [] + }, { "logicalArtifactId": "client-policy-state", "coverage": "absent", @@ -233,6 +238,13 @@ "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, diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs index e412505e8..baab7ff31 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -651,6 +651,8 @@ 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", diff --git a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs index 6d8945337..0e817deb8 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_intake.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_intake.rs @@ -1484,7 +1484,7 @@ 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(), 17); + assert_eq!(declared.len(), 18); assert_eq!(intake.groups.len(), declared.len()); assert!(intake .groups @@ -1663,7 +1663,7 @@ fn missing_access_denied_and_capped_sources_remain_exact_coverage_states() { .all(|group| group.coverage == SccmCoverageState::Absent)); assert_eq!( missing.coverage_gaps.len(), - 18, + 19, "the shared LocationServices declaration contributes one gap to each consumer group, while maintenance, reboot, and extended workflow groups remain explicit" ); assert_eq!( From cedda7f6aa0d9767243a7fa0fc3a524c80673124 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 05:34:32 -0400 Subject: [PATCH 409/422] style: apply workspace rustfmt --- .../cmtraceopen-parser/src/esp/redaction.rs | 3 +- crates/cmtraceopen-parser/src/esp/reducer.rs | 5 ++- crates/cmtraceopen-parser/src/esp/timeline.rs | 5 ++- .../tests/esp_diagnostics.rs | 31 ++++++++++++++----- src-tauri/src/commands/recent_entries.rs | 5 +-- src-tauri/src/commands/system_preferences.rs | 3 +- src-tauri/src/esp/process.rs | 3 +- src-tauri/src/esp/registry.rs | 6 +++- src-tauri/src/esp/relaunch.rs | 5 +-- src-tauri/src/esp/system.rs | 6 ++-- src-tauri/src/graph_api/esp.rs | 16 +++------- src-tauri/src/graph_api/models.rs | 5 +-- src-tauri/src/intune/evtx_parser.rs | 3 +- src-tauri/src/macos_diag/unified_log.rs | 4 +-- src-tauri/src/menu.rs | 13 ++++---- src-tauri/src/sysmon/evtx_parser.rs | 5 +-- src-tauri/tests/esp_diagnostics_sources.rs | 17 +++++----- 17 files changed, 68 insertions(+), 67 deletions(-) diff --git a/crates/cmtraceopen-parser/src/esp/redaction.rs b/crates/cmtraceopen-parser/src/esp/redaction.rs index 41d906287..e4260b23f 100644 --- a/crates/cmtraceopen-parser/src/esp/redaction.rs +++ b/crates/cmtraceopen-parser/src/esp/redaction.rs @@ -1369,8 +1369,7 @@ fn redact_text_for_context(value: &str, context: TextRedactionContext) -> String // the MAC matcher could pick up decimal sub-authority pairs inside it, and // IPv4 runs before IPv6 so an IPv4-mapped IPv6 address cannot leak its dotted // tail. - let redacted = - azure_storage_credential_pattern().replace_all(&redacted, "${prefix}[redacted]"); + let redacted = azure_storage_credential_pattern().replace_all(&redacted, "${prefix}[redacted]"); let redacted = ipv4_address_pattern().replace_all(&redacted, REDACTED); let redacted = mac_address_pattern().replace_all(&redacted, REDACTED); let redacted = ipv6_address_pattern().replace_all(&redacted, REDACTED); diff --git a/crates/cmtraceopen-parser/src/esp/reducer.rs b/crates/cmtraceopen-parser/src/esp/reducer.rs index e40a00b9c..a0297fd55 100644 --- a/crates/cmtraceopen-parser/src/esp/reducer.rs +++ b/crates/cmtraceopen-parser/src/esp/reducer.rs @@ -3137,7 +3137,10 @@ fn sidecar_app_state_observation( ordinal: usize, observation: &EspRegistryObservation, ) -> Option { - let field = if observation.value_name.eq_ignore_ascii_case("InstallationState") { + let field = if observation + .value_name + .eq_ignore_ascii_case("InstallationState") + { SidecarAppField::InstallationState } else if observation.value_name.eq_ignore_ascii_case("ErrorHresult") { SidecarAppField::ErrorHresult diff --git a/crates/cmtraceopen-parser/src/esp/timeline.rs b/crates/cmtraceopen-parser/src/esp/timeline.rs index 8d68f019b..41e8763fa 100644 --- a/crates/cmtraceopen-parser/src/esp/timeline.rs +++ b/crates/cmtraceopen-parser/src/esp/timeline.rs @@ -115,7 +115,10 @@ mod tests { // "...05.250Z" before "...05Z" because '.' (0x2E) < 'Z' (0x5A), which // inverts chronology; the parsed-instant key must keep 05 before 05.250. let entries = vec![ - (0usize, timeline_entry("timeline|a|b|0", "2026-07-15T12:00:05Z")), + ( + 0usize, + timeline_entry("timeline|a|b|0", "2026-07-15T12:00:05Z"), + ), ( 1usize, timeline_entry("timeline|a|b|1", "2026-07-15T12:00:05.250Z"), diff --git a/crates/cmtraceopen-parser/tests/esp_diagnostics.rs b/crates/cmtraceopen-parser/tests/esp_diagnostics.rs index 589a1d9b7..e49815daf 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(); @@ -10471,7 +10483,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/src-tauri/src/commands/recent_entries.rs b/src-tauri/src/commands/recent_entries.rs index eb4e578e2..1fe1dd628 100644 --- a/src-tauri/src/commands/recent_entries.rs +++ b/src-tauri/src/commands/recent_entries.rs @@ -532,10 +532,7 @@ mod tests { use std::thread; let dir = tempdir().expect("tempdir"); - let state = Arc::new(RecentEntriesState::load( - dir.path().to_path_buf(), - &["log"], - )); + let state = Arc::new(RecentEntriesState::load(dir.path().to_path_buf(), &["log"])); let threads: Vec<_> = (0..8) .map(|index| { diff --git a/src-tauri/src/commands/system_preferences.rs b/src-tauri/src/commands/system_preferences.rs index ad94742e2..fd429b2bd 100644 --- a/src-tauri/src/commands/system_preferences.rs +++ b/src-tauri/src/commands/system_preferences.rs @@ -116,8 +116,7 @@ pub fn set_always_on_top( } if let Some(menu) = app.menu() { - if let Some(MenuItemKind::Check(item)) = - menu.get(crate::menu::MENU_ID_WINDOW_ALWAYS_ON_TOP) + if let Some(MenuItemKind::Check(item)) = menu.get(crate::menu::MENU_ID_WINDOW_ALWAYS_ON_TOP) { let _ = item.set_checked(enabled); } diff --git a/src-tauri/src/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/relaunch.rs b/src-tauri/src/esp/relaunch.rs index f8040d3c7..ed1547b40 100644 --- a/src-tauri/src/esp/relaunch.rs +++ b/src-tauri/src/esp/relaunch.rs @@ -264,10 +264,7 @@ fn elevated_working_directory(exe: &std::path::Path) -> Vec { use std::iter::once; use std::os::windows::ffi::OsStrExt; - if let Some(parent) = exe - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { + if let Some(parent) = exe.parent().filter(|parent| !parent.as_os_str().is_empty()) { return parent.as_os_str().encode_wide().chain(once(0)).collect(); } 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/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 02cff0ddf..b8363ab48 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -1304,7 +1304,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. @@ -2229,10 +2232,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] @@ -2274,7 +2274,8 @@ mod tests { assert_eq!(hash.len(), 16, "expected a full 64-bit digest"); assert!( - hash.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), + hash.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), "expected lowercase hex, got {hash}" ); } diff --git a/src-tauri/src/sysmon/evtx_parser.rs b/src-tauri/src/sysmon/evtx_parser.rs index 68b18b912..dc74b4a96 100644 --- a/src-tauri/src/sysmon/evtx_parser.rs +++ b/src-tauri/src/sysmon/evtx_parser.rs @@ -274,10 +274,7 @@ pub fn build_summary( // for this event. String-only events can still update earliest/latest // even when other events had numeric timestamps. let ts = event.timestamp.as_str(); - if earliest_ts - .as_deref() - .is_none_or(|existing| ts < existing) - { + if earliest_ts.as_deref().is_none_or(|existing| ts < existing) { earliest_ts = Some(event.timestamp.clone()); } if latest_ts.as_deref().is_none_or(|existing| ts > existing) { diff --git a/src-tauri/tests/esp_diagnostics_sources.rs b/src-tauri/tests/esp_diagnostics_sources.rs index 0a048c397..cdd1f832c 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; @@ -8043,9 +8042,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")) })); } From 47068b057cb666b561041f6892683f2984fd1450 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 05:43:28 -0400 Subject: [PATCH 410/422] test(sccm): canonicalize health oracle JSON --- .../tests/sccm_client_health.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_health.rs b/crates/cmtraceopen-parser/tests/sccm_client_health.rs index 7689de6cc..54b19dfae 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_health.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_health.rs @@ -338,13 +338,30 @@ fn translate_artifact_ids(value: &mut Value, artifact_ids: &BTreeMap 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); - normalized + canonicalize_json(normalized) } fn admitted_record(phase: &str) -> SccmClientAdmittedEvidence { From ce1d330275c9844b4438bc9ed03c26cf69c304b4 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 05:47:11 -0400 Subject: [PATCH 411/422] fix(sccm): use typed SUP rotation guard --- .../src/sccm/correlation.rs | 11 +- .../sccm_server_software_update_point.rs | 127 +++++++++++++++++- 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/crates/cmtraceopen-parser/src/sccm/correlation.rs b/crates/cmtraceopen-parser/src/sccm/correlation.rs index be822958e..aa1acb7a2 100644 --- a/crates/cmtraceopen-parser/src/sccm/correlation.rs +++ b/crates/cmtraceopen-parser/src/sccm/correlation.rs @@ -19,9 +19,9 @@ use super::server::windows::{ SccmDistributionPointContentAnalysis, SccmDistributionPointContentState, SccmManagementPointAnalysis, SccmManagementPointState, SccmSoftwareUpdatePointAnalysis, SccmSoftwareUpdatePointDisposition, SccmSoftwareUpdatePointProfileSelection, - 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, + 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, @@ -610,9 +610,8 @@ impl SccmUpdatesSoftwareUpdatePointInput { .iter() .any(|item| item.reason.to_ascii_lowercase().contains("rotation")), server_rotation_complete: !server.source_local_observations.iter().any(|item| { - item.observation_id - .to_ascii_lowercase() - .contains("rotation") + item.classification + == SccmSoftwareUpdatePointSourceLocalClassification::RotationSplit }), input_capped: false, } diff --git a/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs b/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs index c89d0b883..677cf6889 100644 --- a/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs +++ b/crates/cmtraceopen-parser/tests/sccm_server_software_update_point.rs @@ -3,7 +3,14 @@ use std::path::{Path, PathBuf}; use cmtraceopen_parser::sccm::server::windows::{ analyze_software_update_point, assess_server_intake, SccmServerArtifactPayload, - SccmServerIntakeAssessment, + 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; @@ -274,6 +281,39 @@ fn production_projection(mut expected: Value) -> Value { 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 { @@ -289,6 +329,91 @@ fn every_committed_scenario_runs_through_the_exported_production_analyzer() { } } +#[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"); From 112bc4b55166567095db8a76662152ebdc8720f5 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 05:50:24 -0400 Subject: [PATCH 412/422] test(sccm): canonicalize inventory oracle JSON --- .../tests/sccm_client_inventory.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs b/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs index 4af576f16..7f6921467 100644 --- a/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs +++ b/crates/cmtraceopen-parser/tests/sccm_client_inventory.rs @@ -290,12 +290,32 @@ fn translate_admitted_artifact_ids( } } +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}")) From c7d7085f14523c3f9305d808b029c7c35bf60876 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 09:47:16 -0400 Subject: [PATCH 413/422] docs(sccm): plan main integration --- .../2026-08-04-sccm-317-main-integration.md | 184 ++++++++++++++++++ library.md | 1 + 2 files changed, 185 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-sccm-317-main-integration.md 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..7c44cbe4e --- /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 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 +cargo fmt --all -- --check +git diff --check +test -z "$(git status --porcelain)" +``` + +Expected: every command exits `0` and the worktree remains clean. + +### 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/library.md b/library.md index 55913d2a0..1db1e44ee 100644 --- a/library.md +++ b/library.md @@ -3,3 +3,4 @@ - 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]] From 5d20d4a0a54ed2780ca3813b4c6d0b4d30e1b1ca Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 09:51:33 -0400 Subject: [PATCH 414/422] docs(sccm): record inherited fmt baseline --- .../plans/2026-08-04-sccm-317-main-integration.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 7c44cbe4e..d9ef1edd4 100644 --- a/docs/superpowers/plans/2026-08-04-sccm-317-main-integration.md +++ b/docs/superpowers/plans/2026-08-04-sccm-317-main-integration.md @@ -4,7 +4,7 @@ **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 and obtain independent review of the new merge SHA before changing PR readiness. +**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 @@ -150,12 +150,12 @@ Run: ```bash npx tsc --noEmit -cargo fmt --all -- --check +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. +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 From b11093fa38b539548fd7b6d4380aa286e5ba16d3 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 10:11:34 -0400 Subject: [PATCH 415/422] fix(ci): satisfy Rust 1.97 hierarchy lint --- ...er_hierarchy_and_replication_fixture_contract.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 index feb0f9cba..f8404931c 100644 --- 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 @@ -1573,17 +1573,16 @@ fn identity_and_schema_failures(scenario: &str, manifest: &Value, expected: &Val } } } - Some("absent" | "accessDenied" | "skipped" | "unsupported") => { + 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" - )); - } + || artifact["rotation"].get("fragmentComplete").is_some() => + { + failures.push(format!( + "{artifact_id}: nonphysical state invents physical provenance" + )); } _ => {} } From 27c28c0fed1b3f68e7f880c13c3948bd5744a156 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 16:06:04 -0400 Subject: [PATCH 416/422] docs(sccm): plan native product path rework --- ...6-08-04-sccm-native-product-path-rework.md | 294 ++++++++++++++++++ library.md | 1 + 2 files changed, 295 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-sccm-native-product-path-rework.md 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/library.md b/library.md index 1db1e44ee..db5ce0c9c 100644 --- a/library.md +++ b/library.md @@ -4,3 +4,4 @@ - 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]] From b2fe56339c916a64512264447207aac777abacba Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 4 Aug 2026 16:17:50 -0400 Subject: [PATCH 417/422] feat(sccm): add native diagnostics workspace --- src/lib/commands.test.ts | 39 ++ src/lib/commands.ts | 16 + src/types/log.ts | 1 + src/workspaces/registry.test.ts | 25 + src/workspaces/registry.ts | 2 + src/workspaces/sccm/SccmWorkspace.test.tsx | 217 +++++++++ src/workspaces/sccm/SccmWorkspace.tsx | 385 +++++++++++++++ src/workspaces/sccm/index.ts | 26 + src/workspaces/sccm/sccm-store.test.ts | 42 ++ src/workspaces/sccm/sccm-store.ts | 43 ++ src/workspaces/sccm/sccm-workspace.css | 542 +++++++++++++++++++++ src/workspaces/sccm/types.ts | 78 +++ 12 files changed, 1416 insertions(+) create mode 100644 src/workspaces/sccm/SccmWorkspace.test.tsx create mode 100644 src/workspaces/sccm/SccmWorkspace.tsx create mode 100644 src/workspaces/sccm/index.ts create mode 100644 src/workspaces/sccm/sccm-store.test.ts create mode 100644 src/workspaces/sccm/sccm-store.ts create mode 100644 src/workspaces/sccm/sccm-workspace.css create mode 100644 src/workspaces/sccm/types.ts 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..625678f58 --- /dev/null +++ b/src/workspaces/sccm/SccmWorkspace.test.tsx @@ -0,0 +1,217 @@ +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 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("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..0f1b232f5 --- /dev/null +++ b/src/workspaces/sccm/SccmWorkspace.tsx @@ -0,0 +1,385 @@ +import type { ReactNode } from "react"; +import { Button, Spinner } from "@fluentui/react-components"; +import { + ArchiveRegular, + CheckmarkCircleRegular, + DatabaseSearchRegular, + DismissCircleRegular, + FolderOpenRegular, + 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