From e4ba2273c2c5cdbb694cc4f4893b8931e65aa739 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:01:39 -0400 Subject: [PATCH 1/2] feat(intune): parse Company Portal Windows LocalState logs (#366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `cmtraceopen_parser::intune::portal::windows::company_portal::logs`, a dedicated parser plus canonical evidence document for `%LOCALAPPDATA%\Packages\Microsoft.CompanyPortal_8wekyb3d8bbwe\LocalState\Log_.log` and the sibling `Log._.log` bridge logs. Evidence basis and its limitation --------------------------------- Microsoft documents the path and the `Log_.log` pattern but not the record grammar. Exactly ONE verbatim record has ever been published, from Company Portal app version 12-0-0: 2024-11-15T16:50:07.2850341Z INFO Event None 0 12-0-0 [Configuration Manager Trace Listener] ... Everything here is derived from that single record, so the grammar is version-scoped from the start: - records are read with GrammarVersion::V1; - 12-0-0 is the only validated app version. Any other version still parses with V1 (it is the only grammar that exists) but downgrades the selection to ParserProvenance::Heuristic and the document to Experimental / Low confidence, and names the gap in coverage; - document confidence never reaches High. Raising it requires a second app version captured from a real device. Encoding, newline style, rotation ordering, the full severity vocabulary, and whether payloads genuinely span lines are all unproven from public evidence. Each is handled defensively rather than assumed, and the open items are recorded in the module docs. Detection safety ---------------- `Log_.log` is a generic name that any UWP package can use, so the file name only nominates a candidate. Confirmation requires field 6 to be a hyphenated GUID and field 7 to be a dash-separated version triple. Two negative fixtures prove it: a column-aligned unrelated UWP log with ISO instants and a severity column is refused even when it sits at the exact Company Portal path, and a generic timestamped log stays on the generic parser. Losslessness and privacy ------------------------ The nested legacy ConfigMgr trace text inside the message — including its day-first date — is never stripped or reinterpreted. Records that fail validation keep their original text and are reported through parse_errors and a coverage row rather than dropped. Dedicated severity wins over keyword inference; only an unrecognized token defers to it. The evidence document is redacted by default and reuses the existing ESP free-text rule table rather than growing a second one; the unredacted form is an explicitly named local-only opt-out. The viewer's LogEntry path is never redacted, because it has to show the file the user opened. Also: the CI parser-crate step now runs every test target in the crate (it named a single target, so new targets ran nowhere), adds a parser-crate clippy gate, and `.gitignore`'s `Logs/` rule is un-ignored for the new `logs/` directories, which it was matching case-insensitively on macOS and Windows checkouts. Co-Authored-By: Claude Opus 5 --- .gitattributes | 6 + .github/workflows/cmtrace-ci.yml | 10 +- .gitignore | 5 + crates/cmtraceopen-parser/src/esp/mod.rs | 5 + .../cmtraceopen-parser/src/esp/redaction.rs | 6 +- crates/cmtraceopen-parser/src/intune/mod.rs | 1 + .../src/intune/portal/mod.rs | 9 + .../windows/company_portal/logs/detect.rs | 224 ++++++ .../windows/company_portal/logs/document.rs | 248 +++++++ .../windows/company_portal/logs/entries.rs | 321 ++++++++ .../windows/company_portal/logs/framing.rs | 187 +++++ .../windows/company_portal/logs/grammar.rs | 449 +++++++++++ .../portal/windows/company_portal/logs/mod.rs | 53 ++ .../windows/company_portal/logs/models.rs | 228 ++++++ .../windows/company_portal/logs/redaction.rs | 95 +++ .../portal/windows/company_portal/mod.rs | 3 + .../src/intune/portal/windows/mod.rs | 3 + .../src/models/log_entry.rs | 2 + .../cmtraceopen-parser/src/parser/detect.rs | 159 +++- crates/cmtraceopen-parser/src/parser/mod.rs | 3 + .../tests/company_portal_windows_logs.rs | 695 ++++++++++++++++++ .../logs/v12-0-0/code-tokens/Log_1.log | 2 + .../logs/v12-0-0/encoding-utf16le/Log_1.log | Bin 0 -> 632 bytes .../logs/v12-0-0/encoding-utf8-bom/Log_1.log | 2 + .../v12-0-0/encoding-utf8-nobom/Log_1.log | 2 + .../logs/v12-0-0/invalid-timestamp/Log_1.log | 3 + .../malformed-structural-token/Log_1.log | 3 + .../v12-0-0/multiline-continuation/Log_1.log | 6 + .../negative-generic-timestamped/Log_1.log | 3 + .../v12-0-0/negative-unrelated-uwp/Log_1.log | 3 + .../windows/logs/v12-0-0/redaction/Log_1.log | 4 + .../windows/logs/v12-0-0/rotation/Log_1.log | 2 + .../windows/logs/v12-0-0/rotation/Log_2.log | 2 + .../Log_1.log | 2 + .../logs/v12-0-0/severity-levels/Log_1.log | 6 + .../v12-0-0/truncated-boundaries/Log_1.log | 3 + .../v13-4-2/unknown-app-version/Log_1.log | 2 + references/log-intune-reference.md | 5 +- src-tauri/src/commands/bundle_ops.rs | 1 + .../corpus/company_portal/clean/Log_1.log | 3 + .../corpus/company_portal/negative/Log_1.log | 3 + src-tauri/tests/parser_supported_formats.rs | 38 +- src/lib/column-config.ts | 1 + src/stores/log-store.ts | 4 + src/types/log.ts | 6 +- 45 files changed, 2809 insertions(+), 9 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/intune/portal/mod.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/detect.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/document.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/entries.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/framing.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/grammar.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/mod.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/models.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/redaction.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/mod.rs create mode 100644 crates/cmtraceopen-parser/src/intune/portal/windows/mod.rs create mode 100644 crates/cmtraceopen-parser/tests/company_portal_windows_logs.rs create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/code-tokens/Log_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf16le/Log_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf8-bom/Log_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf8-nobom/Log_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/invalid-timestamp/Log_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/malformed-structural-token/Log_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/multiline-continuation/Log_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/negative-generic-timestamped/Log_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/negative-unrelated-uwp/Log_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/redaction/Log_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/rotation/Log_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/rotation/Log_2.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/same-timestamp-distinct-activity/Log_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/severity-levels/Log_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/truncated-boundaries/Log_1.log create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v13-4-2/unknown-app-version/Log_1.log create mode 100644 src-tauri/tests/corpus/company_portal/clean/Log_1.log create mode 100644 src-tauri/tests/corpus/company_portal/negative/Log_1.log diff --git a/.gitattributes b/.gitattributes index 6f87ca1fe..5a8d511e8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,9 @@ # Real-world log fixtures are byte-sensitive (UTF-8 BOM + CRLF); never normalize them. src-tauri/tests/fixtures/** -text +# Parser-crate fixtures encode BOM, CRLF, and UTF-16LE deliberately; the tests +# assert on the decoded bytes, so a checkout must not rewrite line endings and +# `git diff --check` must not read a deliberate CR as trailing whitespace. +crates/cmtraceopen-parser/tests/fixtures/** -text -whitespace +src-tauri/tests/corpus/company_portal/** -text -whitespace + diff --git a/.github/workflows/cmtrace-ci.yml b/.github/workflows/cmtrace-ci.yml index 208e04bbc..f64f82ad7 100644 --- a/.github/workflows/cmtrace-ci.yml +++ b/.github/workflows/cmtrace-ci.yml @@ -228,8 +228,14 @@ jobs: key: ${{ runner.os }}-esp-cargo-${{ hashFiles('Cargo.lock') }} restore-keys: ${{ runner.os }}-esp-cargo- - - name: Test ESP parser contracts - run: cargo test --locked -p cmtraceopen-parser --test esp_diagnostics + # All parser-crate targets, not just esp_diagnostics: the `check` job runs + # with working-directory src-tauri, which scopes Cargo to cmtrace-open, so + # this is the only place the parser crate's own tests execute. + - name: Test parser crate contracts + run: cargo test --locked -p cmtraceopen-parser + + - name: Clippy parser crate + run: cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings - name: Test native ESP diagnostics run: cargo test --locked -p cmtrace-open --all-features --test esp_diagnostics_sources diff --git a/.gitignore b/.gitignore index dd22297e3..a54af24fd 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,11 @@ docs/ # Test logs Logs/ +# `Logs/` matches case-insensitively on macOS/Windows checkouts, and the +# Company Portal module and its fixture tree both live under a `logs/` +# directory. A parent directory must be re-included explicitly. +!crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/ +!crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/ # Playwright artifacts (HTML reports, run results, MCP browser session output) playwright-report/ diff --git a/crates/cmtraceopen-parser/src/esp/mod.rs b/crates/cmtraceopen-parser/src/esp/mod.rs index 2d859f616..afaeb6d13 100644 --- a/crates/cmtraceopen-parser/src/esp/mod.rs +++ b/crates/cmtraceopen-parser/src/esp/mod.rs @@ -13,3 +13,8 @@ pub use redaction::*; pub use reducer::*; pub use rules::*; pub use timeline::*; + +// Crate-internal: the free-text redaction pipeline is evidence-agnostic, so +// other evidence modules reuse it rather than growing a second rule table that +// could drift out of step with this one. Not part of the published API. +pub(crate) use redaction::redact_text; diff --git a/crates/cmtraceopen-parser/src/esp/redaction.rs b/crates/cmtraceopen-parser/src/esp/redaction.rs index 41d906287..1d2059957 100644 --- a/crates/cmtraceopen-parser/src/esp/redaction.rs +++ b/crates/cmtraceopen-parser/src/esp/redaction.rs @@ -1338,7 +1338,11 @@ fn standalone_digest_match_is_safe_narrative(value: &str, captures: ®ex::Capt && value[matched.end()..].is_empty() } -fn redact_text(value: &str) -> String { +/// Redact arbitrary evidence text we did not author. +/// +/// `pub(crate)` so sibling evidence modules reuse this rule table instead of +/// writing their own; the rules are about text content, not about ESP. +pub(crate) fn redact_text(value: &str) -> String { redact_text_for_context(value, TextRedactionContext::Arbitrary) } diff --git a/crates/cmtraceopen-parser/src/intune/mod.rs b/crates/cmtraceopen-parser/src/intune/mod.rs index 13af0222f..2bec60a21 100644 --- a/crates/cmtraceopen-parser/src/intune/mod.rs +++ b/crates/cmtraceopen-parser/src/intune/mod.rs @@ -4,4 +4,5 @@ pub mod guid_registry; pub mod ime_parser; pub mod models; pub mod policy_parser; +pub mod portal; pub mod timeline; diff --git a/crates/cmtraceopen-parser/src/intune/portal/mod.rs b/crates/cmtraceopen-parser/src/intune/portal/mod.rs new file mode 100644 index 000000000..83d1ea36b --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/mod.rs @@ -0,0 +1,9 @@ +//! Company Portal evidence surfaces, grouped by platform. +//! +//! Company Portal is a first-class Intune surface because it spans sign-in, +//! enrollment, app catalog, compliance, sync, device actions, and support. It +//! is deliberately kept out of the IME and ESP module trees even where the +//! workflows overlap, so a Company Portal artifact is never attributed to an +//! agent that did not write it. + +pub mod windows; diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/detect.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/detect.rs new file mode 100644 index 000000000..a88db0524 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/detect.rs @@ -0,0 +1,224 @@ +//! Detection confirmation for Company Portal Windows LocalState logs. +//! +//! # Why a file name is never enough +//! +//! `Log_.log` is a completely generic name — any UWP package can write one +//! into its own `LocalState` folder. The path may therefore only *nominate* a +//! candidate; the claim has to come from record structure. Confirmation +//! requires field 6 to be a hyphenated GUID **and** field 7 to be a +//! dash-separated version triple, which is what a generic +//! `2024-11-15T16:50:07Z INFO something` line can never satisfy. + +use super::grammar::parse_record_fields; +use super::models::{ + CompanyPortalGrammarSupport, CompanyPortalLogFileIdentity, CompanyPortalLogFileKind, +}; + +/// What a single line contributed to the detection decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CompanyPortalLineClassification { + /// The line validated against the `V1` field grammar. + pub is_record: bool, + /// The record's app version is one the grammar was derived from. + pub app_version_is_validated: bool, +} + +/// Classify one line for the detection sampler. +/// +/// Returns `None` for anything that is not a confirmed record, so the caller's +/// counters only ever rise on structurally proven lines. +pub fn classify_line(line: &str) -> Option { + let fields = parse_record_fields(line)?; + Some(CompanyPortalLineClassification { + is_record: true, + app_version_is_validated: matches!( + fields.app_version.support, + CompanyPortalGrammarSupport::Validated + ), + }) +} + +/// Check whether a line matches the Company Portal Windows log record grammar. +/// +/// House-convention matcher used by `parser::detect` and by tests. +pub fn matches_company_portal_log_record(line: &str) -> bool { + classify_line(line).is_some() +} + +/// Whether a file name follows a LocalState Company Portal log pattern. +/// +/// A *hint only*. `Log_1.log` belongs to every UWP package that wants it. +pub fn is_company_portal_log_file_name(file_name: &str) -> bool { + !matches!( + parse_file_identity(file_name).kind, + CompanyPortalLogFileKind::Unrecognized + ) +} + +/// Derive the file identity from a file name. +/// +/// Recognizes `Log_.log` (the main app log) and `Log._.log` +/// (the ConfigMgr / IME / launcher bridges, which are written by the same +/// logger and share this grammar). Rotation members keep their index so they +/// stay distinct: no published evidence says whether `Log_1` is the newest or +/// the oldest member, so members are never reordered or deduplicated. +pub fn parse_file_identity(file_name: &str) -> CompanyPortalLogFileIdentity { + let unrecognized = || CompanyPortalLogFileIdentity { + file_name: file_name.to_string(), + kind: CompanyPortalLogFileKind::Unrecognized, + bridge_name: None, + rotation_index: None, + }; + + let Some(stem) = strip_suffix_ci(file_name, ".log") else { + return unrecognized(); + }; + let Some(rest) = strip_prefix_ci(stem, "Log") else { + return unrecognized(); + }; + + // `Log_` — the main app log. + if let Some(index) = rest.strip_prefix('_') { + return match parse_rotation_index(index) { + Some(rotation_index) => CompanyPortalLogFileIdentity { + file_name: file_name.to_string(), + kind: CompanyPortalLogFileKind::App, + bridge_name: None, + rotation_index: Some(rotation_index), + }, + None => unrecognized(), + }; + } + + // `Log._` — a bridge log. + let Some(bridge) = rest.strip_prefix('.') else { + return unrecognized(); + }; + let Some((bridge_name, index)) = bridge.rsplit_once('_') else { + return unrecognized(); + }; + match (bridge_name.is_empty(), parse_rotation_index(index)) { + (false, Some(rotation_index)) => CompanyPortalLogFileIdentity { + file_name: file_name.to_string(), + kind: CompanyPortalLogFileKind::Bridge, + bridge_name: Some(bridge_name.to_string()), + rotation_index: Some(rotation_index), + }, + _ => unrecognized(), + } +} + +fn parse_rotation_index(raw: &str) -> Option { + if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + raw.parse().ok() +} + +fn strip_prefix_ci<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { + let candidate = value.get(..prefix.len())?; + candidate + .eq_ignore_ascii_case(prefix) + .then(|| &value[prefix.len()..]) +} + +fn strip_suffix_ci<'a>(value: &'a str, suffix: &str) -> Option<&'a str> { + let split = value.len().checked_sub(suffix.len())?; + let candidate = value.get(split..)?; + candidate + .eq_ignore_ascii_case(suffix) + .then(|| &value[..split]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matcher_accepts_the_published_record() { + assert!(matches_company_portal_log_record( + "2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 [Configuration Manager Trace Listener] started" + )); + } + + #[test] + fn matcher_rejects_generic_and_plain_lines() { + assert!(!matches_company_portal_log_record("Just plain text")); + assert!(!matches_company_portal_log_record( + "2024-01-15 14:30:00 some generic timestamped line" + )); + assert!(!matches_company_portal_log_record( + "2024-11-15T16:50:07.2850341Z INFO App started" + )); + } + + #[test] + fn matcher_rejects_a_column_aligned_log_without_guid_and_version_columns() { + // Seven aligned columns, an ISO instant and a severity token — and it is + // still refused, because field 6 is not a GUID and field 7 is not a + // dash-separated triple. + assert!(!matches_company_portal_log_record( + "2026-05-04T08:12:31.4410000Z INFO Startup Foreground 0 Shell 1.2.3 session started" + )); + } + + #[test] + fn classification_reports_validated_versus_experimental_app_versions() { + let validated = classify_line( + "2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 ok", + ) + .expect("record must classify"); + assert!(validated.app_version_is_validated); + + let experimental = classify_line( + "2026-02-03T09:15:00.1230000Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 13-4-2 ok", + ) + .expect("record must classify"); + assert!(!experimental.app_version_is_validated); + } + + #[test] + fn app_log_identity_keeps_the_rotation_index() { + let identity = parse_file_identity("Log_3.log"); + assert_eq!(identity.kind, CompanyPortalLogFileKind::App); + assert_eq!(identity.rotation_index, Some(3)); + assert_eq!(identity.bridge_name, None); + } + + #[test] + fn bridge_log_identity_keeps_the_bridge_name() { + let identity = parse_file_identity("Log.ConfigurationManagerBridge_1.log"); + assert_eq!(identity.kind, CompanyPortalLogFileKind::Bridge); + assert_eq!( + identity.bridge_name.as_deref(), + Some("ConfigurationManagerBridge") + ); + assert_eq!(identity.rotation_index, Some(1)); + } + + #[test] + fn unrelated_file_names_are_unrecognized() { + for name in [ + "IntuneManagementExtension.log", + "Log.log", + "Log_.log", + "Log_abc.log", + "Log._1.log", + "Logger_1.log", + "Log_1.txt", + ] { + assert_eq!( + parse_file_identity(name).kind, + CompanyPortalLogFileKind::Unrecognized, + "{name}" + ); + assert!(!is_company_portal_log_file_name(name), "{name}"); + } + } + + #[test] + fn file_name_matching_is_case_insensitive() { + assert!(is_company_portal_log_file_name("log_1.LOG")); + assert!(is_company_portal_log_file_name("LOG.BridgeLauncher_2.log")); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/document.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/document.rs new file mode 100644 index 000000000..06710b03a --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/document.rs @@ -0,0 +1,248 @@ +//! Builds the canonical Company Portal Windows log evidence document. +//! +//! There is **no semantic phase classification here**. Company Portal spans +//! sign-in, enrollment, app catalog, compliance, sync, device actions, and +//! support, but not one of those concepts is proven by the single published +//! record, so records stay ordinary parsed log records. Codes and unknown +//! tokens are preserved as text; nothing is promoted to an outcome. + +use super::detect::parse_file_identity; +use super::framing::{frame_records, FramedRecord, FramedRecordKind}; +use super::models::*; +use super::redaction::redacted_export_projection; + +/// Coverage artifact id for the file itself. +const FILE_COVERAGE_ARTIFACT_ID: &str = "companyPortal.windows.logs"; +/// Coverage artifact id for the grammar-version gap. +const GRAMMAR_COVERAGE_ARTIFACT_ID: &str = "companyPortal.windows.logs.grammar"; +const COVERAGE_FAMILY: &str = "company-portal-logs"; + +/// Parse Company Portal log text into a **redacted** evidence document. +/// +/// This is the default entry point: the returned document has already had +/// UPN/email, user-profile paths, SIDs, tenant/serial/token-labelled values, +/// and network identifiers removed. Use +/// [`parse_log_document_preserving_local_values`] only for local rendering that +/// never leaves the machine. +/// +/// `file_path` is used only to derive the file identity — no filesystem access +/// is performed, and only the file name is retained in the document. +pub fn parse_log_document(file_path: &str, content: &str) -> CompanyPortalLogDocument { + redacted_export_projection(&parse_log_document_preserving_local_values( + file_path, content, + )) +} + +/// Parse Company Portal log text without redacting sensitive values. +/// +/// The result carries `redacted: false` and must not be exported, uploaded, or +/// attached to a support case. +pub fn parse_log_document_preserving_local_values( + file_path: &str, + content: &str, +) -> CompanyPortalLogDocument { + let file = parse_file_identity(file_name_of(file_path)); + let lines: Vec<&str> = content.lines().collect(); + let framed = frame_records(&lines); + + let records: Vec = framed + .iter() + .map(|record| build_record(&file.file_name, record)) + .collect(); + + let parsed_count = framed.len() - framed.iter().filter(|r| r.is_parse_error()).count(); + let experimental_count = framed + .iter() + .filter(|record| match &record.kind { + FramedRecordKind::Record(fields) => { + fields.app_version.support == CompanyPortalGrammarSupport::Experimental + } + _ => false, + }) + .count(); + + // A file is only a validated read when it actually produced records and + // every one of them came from an app version the grammar was derived from. + let grammar_support = if parsed_count > 0 && experimental_count == 0 { + CompanyPortalGrammarSupport::Validated + } else { + CompanyPortalGrammarSupport::Experimental + }; + + CompanyPortalLogDocument { + schema_version: COMPANY_PORTAL_WINDOWS_LOGS_SCHEMA_VERSION, + grammar_version: CompanyPortalGrammarVersion::V1, + grammar_support, + // Never `High`: the grammar rests on a single published app version. + confidence: match grammar_support { + CompanyPortalGrammarSupport::Validated => CompanyPortalConfidence::Medium, + CompanyPortalGrammarSupport::Experimental => CompanyPortalConfidence::Low, + }, + redacted: false, + coverage: build_coverage( + &file, + framed.len(), + parsed_count, + experimental_count, + grammar_support, + ), + file, + records, + } +} + +fn build_record(file_name: &str, framed: &FramedRecord<'_>) -> CompanyPortalLogRecord { + let raw_text = framed.raw_text(); + let record_id = format!("companyPortalLog|{file_name}|{}", framed.line_number); + + match &framed.kind { + FramedRecordKind::Record(fields) => CompanyPortalLogRecord { + record_id, + line_number: framed.line_number, + parse_state: CompanyPortalParseState::Parsed, + timestamp: Some(fields.timestamp.clone()), + severity: Some(fields.severity.clone()), + category: Some(fields.category.clone()), + scenario: Some(fields.scenario.clone()), + sequence: Some(fields.sequence), + activity_id: Some(fields.activity_id.clone()), + app_version: Some(fields.app_version.clone()), + component: fields.component.clone(), + message: super::framing::join_lines(&fields.message, &framed.continuations), + raw_text, + }, + FramedRecordKind::Malformed | FramedRecordKind::Orphaned => CompanyPortalLogRecord { + record_id, + line_number: framed.line_number, + parse_state: match framed.kind { + FramedRecordKind::Malformed => CompanyPortalParseState::Malformed, + _ => CompanyPortalParseState::Orphaned, + }, + timestamp: None, + severity: None, + category: None, + scenario: None, + sequence: None, + activity_id: None, + app_version: None, + component: None, + // Nothing was claimed, so the whole record is the message. + message: raw_text.clone(), + raw_text, + }, + } +} + +fn build_coverage( + file: &CompanyPortalLogFileIdentity, + total_records: usize, + parsed_count: usize, + experimental_count: usize, + grammar_support: CompanyPortalGrammarSupport, +) -> Vec { + let mut coverage = Vec::new(); + let unreadable = total_records - parsed_count; + + coverage.push(CompanyPortalCoverage { + artifact_id: FILE_COVERAGE_ARTIFACT_ID.to_string(), + family: COVERAGE_FAMILY.to_string(), + status: if unreadable == 0 { + CompanyPortalCoverageStatus::Available + } else { + CompanyPortalCoverageStatus::ParseFailed + }, + detail: Some(format!( + "{} read {parsed_count} of {total_records} record(s) with grammar V1; \ + {unreadable} record(s) did not match and are preserved verbatim.", + file.file_name + )), + }); + + if grammar_support == CompanyPortalGrammarSupport::Experimental { + coverage.push(CompanyPortalCoverage { + artifact_id: GRAMMAR_COVERAGE_ARTIFACT_ID.to_string(), + family: COVERAGE_FAMILY.to_string(), + status: CompanyPortalCoverageStatus::Unsupported, + detail: Some(if experimental_count > 0 { + format!( + "{experimental_count} record(s) report a Company Portal app version the V1 \ + grammar was not derived from. Fields were read with V1 unchanged and this \ + document is downgraded to low confidence." + ) + } else { + "No record matched the V1 grammar, so no app version could be confirmed." + .to_string() + }), + }); + } + + coverage +} + +/// Last path component, handling both separators. Pure string work — the parser +/// crate performs no filesystem access. +fn file_name_of(file_path: &str) -> &str { + file_path.rsplit(['/', '\\']).next().unwrap_or(file_path) +} + +#[cfg(test)] +mod tests { + use super::*; + + const APP_LOG_PATH: &str = "C:/Users/adele.vance/AppData/Local/Packages/Microsoft.CompanyPortal_8wekyb3d8bbwe/LocalState/Log_1.log"; + + #[test] + fn document_records_the_schema_and_grammar_versions() { + let content = "2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 [Sync] started\n"; + let document = parse_log_document(APP_LOG_PATH, content); + + assert_eq!(document.schema_version, 1); + assert_eq!(document.grammar_version, CompanyPortalGrammarVersion::V1); + assert_eq!( + document.grammar_support, + CompanyPortalGrammarSupport::Validated + ); + assert_eq!(document.confidence, CompanyPortalConfidence::Medium); + assert!(document.redacted); + } + + #[test] + fn document_keeps_only_the_file_name_not_the_user_profile_path() { + let content = "2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 ok\n"; + let document = parse_log_document(APP_LOG_PATH, content); + + assert_eq!(document.file.file_name, "Log_1.log"); + assert_eq!(document.file.rotation_index, Some(1)); + let json = serde_json::to_string(&document).expect("document must serialize"); + assert!(!json.contains("adele.vance")); + } + + #[test] + fn unreadable_records_become_coverage_not_silence() { + let content = "2024-13-45T99:99:99.0000000Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 impossible\n"; + let document = parse_log_document(APP_LOG_PATH, content); + + assert_eq!(document.records.len(), 1); + assert_eq!( + document.records[0].parse_state, + CompanyPortalParseState::Malformed + ); + assert_eq!( + document.coverage[0].status, + CompanyPortalCoverageStatus::ParseFailed + ); + // No record parsed, so no app version could be confirmed either. + assert_eq!( + document.coverage[1].status, + CompanyPortalCoverageStatus::Unsupported + ); + } + + #[test] + fn local_projection_is_marked_unredacted() { + let content = "2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 ok\n"; + let document = parse_log_document_preserving_local_values(APP_LOG_PATH, content); + + assert!(!document.redacted); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/entries.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/entries.rs new file mode 100644 index 000000000..908237139 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/entries.rs @@ -0,0 +1,321 @@ +//! `LogEntry` projection for the log viewer. +//! +//! This is the local-rendering path, so message text is never redacted here — +//! the viewer has to show the file the user opened. Export/evidence callers use +//! `parse_log_document`, which redacts by default. +//! +//! # Field mapping +//! +//! `LogEntry` has no column for a record category, scenario, sequence, or +//! activity id, and forcing them into unrelated columns (`thread`, +//! `operationName`, …) would assert semantics the evidence does not support. +//! Those fields therefore live in [`super::CompanyPortalLogRecord`] only, and +//! the viewer entry carries the four things it does have columns for: +//! timestamp, severity, component, and message. + +use chrono::DateTime; + +use super::framing::{frame_records, join_lines, FramedRecord, FramedRecordKind}; +use super::grammar::CompanyPortalRecordFields; +use super::models::CompanyPortalSeverityLevel; +use crate::models::log_entry::{LogEntry, LogFormat, Severity}; +use crate::parser::severity::detect_severity_from_text; + +/// Parse lines already framed by the pipeline as Company Portal Windows logs. +pub fn parse_lines(lines: &[&str], file_path: &str) -> (Vec, u32) { + let framed = frame_records(lines); + let mut entries = Vec::with_capacity(framed.len()); + let mut parse_errors: u32 = 0; + + for (id, record) in framed.iter().enumerate() { + if record.is_parse_error() { + parse_errors += 1; + } + entries.push(build_entry(id as u64, record, file_path)); + } + + (entries, parse_errors) +} + +fn build_entry(id: u64, framed: &FramedRecord<'_>, file_path: &str) -> LogEntry { + match &framed.kind { + FramedRecordKind::Record(fields) => parsed_entry(id, framed, fields, file_path), + // Nothing validated, so nothing is claimed: the record's original text + // becomes the message and the derived columns stay empty. + FramedRecordKind::Malformed | FramedRecordKind::Orphaned => { + let message = framed.raw_text(); + let severity = detect_severity_from_text(&message); + empty_entry( + id, + framed.line_number, + message, + LogFormat::Plain, + severity, + file_path, + ) + } + } +} + +fn parsed_entry( + id: u64, + framed: &FramedRecord<'_>, + fields: &CompanyPortalRecordFields, + file_path: &str, +) -> LogEntry { + let message = join_lines(&fields.message, &framed.continuations); + + let mut entry = empty_entry( + id, + framed.line_number, + message, + LogFormat::Timestamped, + Severity::Info, + file_path, + ); + + // A present, known severity token always wins; an unrecognized token is the + // only case that defers to keyword inference on the message. + entry.severity = match fields.severity.level { + CompanyPortalSeverityLevel::Verbose | CompanyPortalSeverityLevel::Information => { + Severity::Info + } + CompanyPortalSeverityLevel::Warning => Severity::Warning, + CompanyPortalSeverityLevel::Error | CompanyPortalSeverityLevel::Critical => Severity::Error, + CompanyPortalSeverityLevel::Unknown => detect_severity_from_text(&entry.message), + }; + + // The leading `[Component Name]` is the emitting component and is far more + // useful than the category token; the category is the fallback when the + // message does not open with a bracket. The bracket stays in the message — + // stripping it could not be reversed exactly, and messages must stay + // lossless. + entry.component = Some( + fields + .component + .clone() + .unwrap_or_else(|| fields.category.clone()), + ); + + if let Ok(parsed) = DateTime::parse_from_rfc3339(&fields.timestamp.raw_text) { + entry.timestamp = Some(parsed.timestamp_millis()); + let utc = parsed.naive_utc(); + // Millisecond display precision matches every other parser's column; + // the full 100 ns tick is kept in the evidence document. + entry.timestamp_display = Some(utc.format("%Y-%m-%d %H:%M:%S%.3f").to_string()); + // Field 1 always carries a trailing `Z`. + entry.timezone_offset = Some(0); + } + + entry +} + +fn empty_entry( + id: u64, + line_number: u32, + message: String, + format: LogFormat, + severity: Severity, + file_path: &str, +) -> LogEntry { + LogEntry { + id, + line_number, + message, + component: None, + timestamp: None, + timestamp_display: None, + severity, + thread: None, + thread_display: None, + source_file: None, + format, + file_path: file_path.to_string(), + timezone_offset: None, + 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, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FILE: &str = "Log_1.log"; + + fn record(severity: &str, message: &str) -> String { + format!( + "2024-11-15T16:50:07.2850341Z {severity} Event None 0 \ + 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 {message}" + ) + } + + #[test] + fn dedicated_severity_beats_keyword_inference() { + // "failed" would infer Error, but the record says INFO. + let line = record("INFO", "[Sync] the previous attempt failed and was retried"); + let (entries, errors) = parse_lines(&[&line], FILE); + + assert_eq!(errors, 0); + assert_eq!(entries[0].severity, Severity::Info); + } + + #[test] + fn every_known_severity_token_maps_to_a_level() { + for (token, expected) in [ + ("VERBOSE", Severity::Info), + ("DEBUG", Severity::Info), + ("INFO", Severity::Info), + ("WARN", Severity::Warning), + ("WARNING", Severity::Warning), + ("ERROR", Severity::Error), + ("CRITICAL", Severity::Error), + ("FATAL", Severity::Error), + ] { + let line = record(token, "[Sync] state change"); + let (entries, _) = parse_lines(&[&line], FILE); + assert_eq!(entries[0].severity, expected, "{token}"); + } + } + + #[test] + fn unknown_severity_token_falls_back_to_keyword_inference() { + let line = record("NOTICE", "[Sync] the request failed"); + let (entries, errors) = parse_lines(&[&line], FILE); + + assert_eq!(errors, 0); + assert_eq!(entries[0].severity, Severity::Error); + } + + #[test] + fn component_prefers_the_bracket_and_falls_back_to_the_category() { + let bracketed = record("INFO", "[Configuration Manager Trace Listener] querying"); + let (entries, _) = parse_lines(&[&bracketed], FILE); + assert_eq!( + entries[0].component.as_deref(), + Some("Configuration Manager Trace Listener") + ); + + let bare = record("INFO", "querying without a bracket"); + let (entries, _) = parse_lines(&[&bare], FILE); + assert_eq!(entries[0].component.as_deref(), Some("Event")); + } + + #[test] + fn timestamp_is_read_as_utc_with_millisecond_display() { + let line = record("INFO", "[Sync] started"); + let (entries, _) = parse_lines(&[&line], FILE); + + assert_eq!( + entries[0].timestamp_display.as_deref(), + Some("2024-11-15 16:50:07.285") + ); + assert_eq!(entries[0].timezone_offset, Some(0)); + assert_eq!( + entries[0].timestamp, + Some( + DateTime::parse_from_rfc3339("2024-11-15T16:50:07.2850341Z") + .unwrap() + .timestamp_millis() + ) + ); + } + + #[test] + fn thread_is_never_derived_from_the_sequence_field() { + let line = "2024-11-15T16:50:07.2850341Z INFO Event None 4271 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 [Sync] started"; + let (entries, _) = parse_lines(&[line], FILE); + + assert_eq!(entries[0].thread, None); + assert_eq!(entries[0].thread_display, None); + } + + #[test] + fn a_malformed_record_is_preserved_verbatim_as_a_parse_error() { + let line = "2024-13-45T99:99:99.0000000Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 impossible instant"; + let (entries, errors) = parse_lines(&[line], FILE); + + assert_eq!(errors, 1); + assert_eq!(entries[0].message, line); + assert_eq!(entries[0].format, LogFormat::Plain); + assert!(entries[0].timestamp.is_none()); + assert!(entries[0].timestamp_display.is_none()); + assert!(entries[0].component.is_none()); + } + + #[test] + fn continuation_lines_join_the_record_and_line_numbers_stay_on_the_head() { + let head = record("ERROR", "[Install] request rejected"); + let lines = vec![ + head.as_str(), + "System.Net.Http.HttpRequestException: 403", + " at Microsoft.Management.Services.PortalClient.SendAsync()", + ]; + let (entries, errors) = parse_lines(&lines, FILE); + + assert_eq!(errors, 0); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].line_number, 1); + assert_eq!( + entries[0].message, + "[Install] request rejected\nSystem.Net.Http.HttpRequestException: 403\n at Microsoft.Management.Services.PortalClient.SendAsync()" + ); + } + + #[test] + fn entry_ids_are_contiguous_across_mixed_records() { + let good = record("INFO", "[Sync] ok"); + let lines = vec![ + "orphaned tail of a rotated file", + good.as_str(), + "2024-13-45T99:99:99.0000000Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 bad", + ]; + let (entries, errors) = parse_lines(&lines, FILE); + + assert_eq!(errors, 2); + assert_eq!( + entries.iter().map(|entry| entry.id).collect::>(), + vec![0, 1, 2] + ); + assert_eq!( + entries + .iter() + .map(|entry| entry.line_number) + .collect::>(), + vec![1, 2, 3] + ); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/framing.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/framing.rs new file mode 100644 index 000000000..448687bc0 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/framing.rs @@ -0,0 +1,187 @@ +//! Record framing shared by the `LogEntry` projection and the evidence +//! document, so both agree on exactly where a record starts and ends. +//! +//! # The continuation rule +//! +//! Whether Company Portal ever writes a payload across several lines is not +//! established by any published evidence. `V1` therefore uses the reading that +//! is lossless either way: a line that does not open a record belongs to the +//! record above it. +//! +//! A line that *does* look like a record start but fails validation is not a +//! continuation — it closes the previous record and is reported as a malformed +//! record, so a corrupted header can never be silently absorbed into the +//! message of a healthy record. + +use super::grammar::{looks_like_record_start, parse_record_fields, CompanyPortalRecordFields}; + +/// Why a framed record exists. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum FramedRecordKind { + /// The head line validated against the grammar. + Record(Box), + /// The head line opened a record but failed validation. + Malformed, + /// Text that arrived before any record started — a truncated leading + /// fragment of a rotated file. + Orphaned, +} + +/// One record: a head line plus every line that followed it before the next +/// record started. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct FramedRecord<'a> { + pub kind: FramedRecordKind, + /// 1-based line number of the head line. + pub line_number: u32, + /// The head line with trailing whitespace removed. + pub head: &'a str, + /// Continuation lines with trailing whitespace removed; leading + /// indentation is kept because it is part of a stack trace's meaning. + pub continuations: Vec<&'a str>, +} + +impl FramedRecord<'_> { + /// The record's original text, head plus continuations. + pub(super) fn raw_text(&self) -> String { + join_lines(self.head, &self.continuations) + } + + /// `true` when the record could not be read as a well-formed record. + pub(super) fn is_parse_error(&self) -> bool { + !matches!(self.kind, FramedRecordKind::Record(_)) + } +} + +/// Group physical lines into records. +pub(super) fn frame_records<'a>(lines: &[&'a str]) -> Vec> { + let mut records: Vec> = Vec::new(); + + for (index, line) in lines.iter().enumerate() { + // Only trailing whitespace is stripped before the record test: a record + // starts in column 0, so an indented line is a continuation even if it + // would otherwise look like a header. + let trimmed = line.trim_end(); + + if let Some(fields) = parse_record_fields(trimmed) { + records.push(FramedRecord { + kind: FramedRecordKind::Record(Box::new(fields)), + line_number: (index + 1) as u32, + head: trimmed, + continuations: Vec::new(), + }); + continue; + } + + if looks_like_record_start(trimmed) { + records.push(FramedRecord { + kind: FramedRecordKind::Malformed, + line_number: (index + 1) as u32, + head: trimmed, + continuations: Vec::new(), + }); + continue; + } + + if trimmed.is_empty() { + continue; + } + + match records.last_mut() { + Some(pending) => pending.continuations.push(trimmed), + None => records.push(FramedRecord { + kind: FramedRecordKind::Orphaned, + line_number: (index + 1) as u32, + head: trimmed, + continuations: Vec::new(), + }), + } + } + + records +} + +/// Join a head line with its continuations using `\n`. +pub(super) fn join_lines(head: &str, continuations: &[&str]) -> String { + if continuations.is_empty() { + return head.to_string(); + } + let mut joined = String::with_capacity( + head.len() + + continuations + .iter() + .map(|line| line.len() + 1) + .sum::(), + ); + joined.push_str(head); + for line in continuations { + joined.push('\n'); + joined.push_str(line); + } + joined +} + +#[cfg(test)] +mod tests { + use super::*; + + const HEAD: &str = "2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 install failed"; + + #[test] + fn continuation_lines_attach_to_the_record_above() { + let lines = vec![ + HEAD, + "System.Net.Http.HttpRequestException: response status 403", + " at Microsoft.Management.Services.PortalClient.SendAsync()", + ]; + let records = frame_records(&lines); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].continuations.len(), 2); + assert_eq!( + records[0].raw_text(), + format!( + "{HEAD}\nSystem.Net.Http.HttpRequestException: response status 403\n at Microsoft.Management.Services.PortalClient.SendAsync()" + ) + ); + } + + #[test] + fn a_malformed_head_closes_the_previous_record() { + let lines = vec![ + HEAD, + "2024-13-45T99:99:99.0000000Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 impossible", + " trailing detail", + ]; + let records = frame_records(&lines); + + assert_eq!(records.len(), 2); + assert!(!records[0].is_parse_error()); + assert_eq!(records[1].kind, FramedRecordKind::Malformed); + // The malformed record still gathers its own continuations, so a stack + // trace under a corrupt header is not scattered across entries. + assert_eq!(records[1].continuations, vec![" trailing detail"]); + } + + #[test] + fn text_before_any_record_is_orphaned_not_dropped() { + let lines = vec!["ry all instances of CCM_Application)", HEAD]; + let records = frame_records(&lines); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].kind, FramedRecordKind::Orphaned); + assert_eq!(records[0].head, "ry all instances of CCM_Application)"); + assert_eq!(records[0].line_number, 1); + assert_eq!(records[1].line_number, 2); + } + + #[test] + fn blank_lines_do_not_open_records() { + let lines = vec!["", HEAD, " ", ""]; + let records = frame_records(&lines); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].line_number, 2); + assert!(records[0].continuations.is_empty()); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/grammar.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/grammar.rs new file mode 100644 index 000000000..38a5d5c1e --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/grammar.rs @@ -0,0 +1,449 @@ +//! Company Portal Windows log record grammar, version `V1`. +//! +//! # Evidence basis +//! +//! Exactly one verbatim Company Portal `Log_.log` record has ever been +//! published, from app version `12-0-0`: +//! +//! ```text +//! 2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 [Configuration Manager Trace Listener] 15/11/2024 16:50:07: SCClient Information: 1: Getting all instances of CCM_Application (Microsoft.SoftwareCenter.Client.Data.Shared.WmiDataConnectorShared at GetAllApplicationsWithType) +//! ``` +//! +//! Fields are column-aligned and separated by runs of two or more spaces; the +//! message is the remainder of the line and keeps its own internal spacing. +//! +//! | # | Field | Sample | Rule | +//! |---|---|---|---| +//! | 1 | timestamp | `2024-11-15T16:50:07.2850341Z` | .NET round-trip (`"O"`) UTC instant: `T` separator, exactly 7 fractional digits, trailing `Z` | +//! | 2 | severity | `INFO` | Dedicated severity token | +//! | 3 | category | `Event` | Record category/kind | +//! | 4 | scenario | `None` | Scenario name; `None` is .NET's null rendering, not a missing field | +//! | 5 | sequence | `0` | Unsigned integer, semantics unproven | +//! | 6 | activity id | `1487dc30-…` | Hyphenated GUID | +//! | 7 | app version | `12-0-0` | Dash-separated version triple | +//! | 8 | message | `[Configuration Manager Trace Listener] …` | Remainder of the line, verbatim | +//! +//! # What this grammar deliberately does not do +//! +//! The published message embeds a nested legacy ConfigMgr trace line with its +//! own **day-first** date (`15/11/2024`), a `SCClient Information: 1:` prefix, +//! and a trailing `(Type at Method)`. None of that is stripped or +//! reinterpreted — it is message text. Only the leading `[...]` is *surfaced* +//! as a component, and even then it is left in the message because the exact +//! spacing after `]` cannot be reconstructed. + +use chrono::{DateTime, SecondsFormat, Utc}; + +use super::models::{ + CompanyPortalAppVersion, CompanyPortalGrammarSupport, CompanyPortalSeverity, + CompanyPortalSeverityLevel, CompanyPortalTimestamp, CompanyPortalTimestampKind, + CompanyPortalVersionTriple, +}; + +/// Number of fixed fields that precede the free-text message. +const LEADING_FIELD_COUNT: usize = 7; + +/// Length of a hyphenated GUID in its canonical 8-4-4-4-12 form. +const GUID_LEN: usize = 36; + +/// Fractional-second digits emitted by .NET's round-trip (`"O"`) format. This +/// is fixed-width, so requiring it exactly costs nothing and is a large part of +/// what keeps the matcher off arbitrary ISO-timestamped logs. +const FRACTIONAL_DIGITS: usize = 7; + +/// App versions whose record layout has actually been observed. +/// +/// Only `12-0-0` has a published verbatim record. Anything else still parses +/// with the `V1` grammar — it is the only grammar there is — but is reported as +/// [`CompanyPortalGrammarSupport::Experimental`] instead of being presented as +/// a validated read. +const VALIDATED_APP_VERSIONS: &[CompanyPortalVersionTriple] = &[CompanyPortalVersionTriple { + major: 12, + minor: 0, + patch: 0, +}]; + +/// A record header that validated against the `V1` field grammar. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompanyPortalRecordFields { + pub timestamp: CompanyPortalTimestamp, + pub severity: CompanyPortalSeverity, + pub category: String, + pub scenario: String, + pub sequence: u64, + pub activity_id: String, + pub app_version: CompanyPortalAppVersion, + pub component: Option, + pub message: String, +} + +/// Cheap test for "this line is trying to start a record". +/// +/// Used to decide whether a line that fails full validation is a *malformed +/// record* (flush the pending record, report a parse error) or a *continuation* +/// of the record above it. Deliberately looser than [`parse_record_fields`]: +/// only the leading `YYYY-MM-DDT` shape is required. +pub fn looks_like_record_start(line: &str) -> bool { + let bytes = line.as_bytes(); + if bytes.len() < 11 { + return false; + } + bytes[0..4].iter().all(u8::is_ascii_digit) + && bytes[4] == b'-' + && bytes[5..7].iter().all(u8::is_ascii_digit) + && bytes[7] == b'-' + && bytes[8..10].iter().all(u8::is_ascii_digit) + && bytes[10] == b'T' +} + +/// Parse a single physical line against the `V1` grammar. +/// +/// Returns `None` unless every one of fields 1-7 validates. Callers treat +/// `None` as "preserve this line verbatim", never as "drop it". +pub fn parse_record_fields(line: &str) -> Option { + // Cheap reject first: this runs on every sampled line of every file the app + // opens, and the split below walks the whole line. + if !looks_like_record_start(line) { + return None; + } + + let (fields, message) = split_leading_fields(line)?; + + let timestamp = parse_utc_instant(fields[0])?; + let activity_id = parse_activity_id(fields[5])?; + let triple = parse_version_triple(fields[6])?; + let sequence: u64 = fields[4].parse().ok()?; + + // Fields 3 and 4 carry no validatable shape, but an empty column would mean + // the split landed somewhere other than a real record boundary. + if fields[1].is_empty() || fields[2].is_empty() || fields[3].is_empty() { + return None; + } + + let support = if VALIDATED_APP_VERSIONS.contains(&triple) { + CompanyPortalGrammarSupport::Validated + } else { + CompanyPortalGrammarSupport::Experimental + }; + + Some(CompanyPortalRecordFields { + timestamp, + severity: CompanyPortalSeverity { + raw_text: fields[1].to_string(), + level: severity_level(fields[1]), + }, + category: fields[2].to_string(), + scenario: fields[3].to_string(), + sequence, + activity_id, + app_version: CompanyPortalAppVersion { + raw_text: fields[6].to_string(), + triple, + support, + }, + component: leading_component(message).map(str::to_string), + message: message.to_string(), + }) +} + +/// Split a record into its seven leading fields plus the message remainder. +/// +/// Fields are separated by runs of two or more spaces. A single space stays +/// inside a field, so multi-word categories and scenarios survive, and once the +/// seventh field is closed everything left is the message — including the runs +/// of spaces the published sample has in front of `(Type at Method)`. +/// +/// Hand-rolled rather than a regex: this is linear with no backtracking, and it +/// runs on every sampled line of every file opened. +fn split_leading_fields(line: &str) -> Option<([&str; LEADING_FIELD_COUNT], &str)> { + let mut fields = [""; LEADING_FIELD_COUNT]; + let mut filled = 0usize; + let mut field_start = 0usize; + let mut index = 0usize; + let bytes = line.as_bytes(); + + while index < bytes.len() { + if bytes[index] != b' ' { + index += 1; + continue; + } + let run_start = index; + while index < bytes.len() && bytes[index] == b' ' { + index += 1; + } + if index - run_start < 2 { + continue; + } + fields[filled] = &line[field_start..run_start]; + filled += 1; + field_start = index; + if filled == LEADING_FIELD_COUNT { + return Some((fields, &line[field_start..])); + } + } + + // A record whose message is empty ends immediately after field 7. + if filled == LEADING_FIELD_COUNT - 1 && field_start < line.len() { + fields[filled] = &line[field_start..]; + return Some((fields, "")); + } + + None +} + +/// Parse field 1 as a .NET round-trip UTC instant. +fn parse_utc_instant(raw: &str) -> Option { + if !has_round_trip_shape(raw) { + return None; + } + // Shape alone does not make an instant real; 2024-13-45T99:99:99.0000000Z + // has to be rejected here rather than resolved to something plausible. + let parsed = DateTime::parse_from_rfc3339(raw).ok()?.with_timezone(&Utc); + Some(CompanyPortalTimestamp { + raw_text: raw.to_string(), + // Same canonical serialization the ESP pipeline uses, so equal instants + // render byte-identically across modules. + normalized_utc: Some(parsed.to_rfc3339_opts(SecondsFormat::AutoSi, true)), + kind: CompanyPortalTimestampKind::Utc, + }) +} + +/// `YYYY-MM-DDTHH:MM:SS.fffffffZ` — exactly seven fractional digits, mandatory +/// `Z`. +fn has_round_trip_shape(raw: &str) -> bool { + // `d` marks a digit; every other byte must match literally. + const HEAD: &str = "dddd-dd-ddTdd:dd:dd."; + if raw.len() != HEAD.len() + FRACTIONAL_DIGITS + 1 { + return false; + } + let bytes = raw.as_bytes(); + bytes[..HEAD.len()] + .iter() + .zip(HEAD.bytes()) + .all(|(actual, expected)| match expected { + b'd' => actual.is_ascii_digit(), + _ => *actual == expected, + }) + && bytes[HEAD.len()..HEAD.len() + FRACTIONAL_DIGITS] + .iter() + .all(u8::is_ascii_digit) + && bytes[raw.len() - 1] == b'Z' +} + +/// Parse field 6 as a hyphenated GUID. +/// +/// Every published sample is lowercase, but hex case carries no meaning, so +/// both cases are accepted; the raw text is returned unchanged either way. +fn parse_activity_id(raw: &str) -> Option { + const SHAPE: &str = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"; + debug_assert_eq!(SHAPE.len(), GUID_LEN); + if raw.len() != GUID_LEN { + return None; + } + let valid = raw + .bytes() + .zip(SHAPE.bytes()) + .all(|(actual, expected)| match expected { + b'x' => actual.is_ascii_hexdigit(), + _ => actual == expected, + }); + valid.then(|| raw.to_string()) +} + +/// Parse field 7 as a `--` triple. +fn parse_version_triple(raw: &str) -> Option { + let mut parts = raw.split('-'); + let major = parse_version_component(parts.next()?)?; + let minor = parse_version_component(parts.next()?)?; + let patch = parse_version_component(parts.next()?)?; + if parts.next().is_some() { + return None; + } + Some(CompanyPortalVersionTriple { + major, + minor, + patch, + }) +} + +fn parse_version_component(raw: &str) -> Option { + if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + raw.parse().ok() +} + +/// Map field 2 onto a level. Tokens outside the known vocabulary map to +/// [`CompanyPortalSeverityLevel::Unknown`] and keep their raw text, so a future +/// level never silently becomes `Information`. +fn severity_level(token: &str) -> CompanyPortalSeverityLevel { + match token.to_ascii_uppercase().as_str() { + "INFO" => CompanyPortalSeverityLevel::Information, + "WARN" | "WARNING" => CompanyPortalSeverityLevel::Warning, + "ERROR" => CompanyPortalSeverityLevel::Error, + "VERBOSE" | "DEBUG" => CompanyPortalSeverityLevel::Verbose, + "CRITICAL" | "FATAL" => CompanyPortalSeverityLevel::Critical, + _ => CompanyPortalSeverityLevel::Unknown, + } +} + +/// Return the leading `[Component Name]` of a message, when the message opens +/// with a balanced bracket that contains no nested bracket. +pub fn leading_component(message: &str) -> Option<&str> { + let rest = message.strip_prefix('[')?; + let end = rest.find(']')?; + let name = &rest[..end]; + if name.is_empty() || name.contains('[') { + return None; + } + Some(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The only publicly published Company Portal record. + const PUBLISHED_RECORD: &str = "2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 [Configuration Manager Trace Listener] 15/11/2024 16:50:07: SCClient Information: 1: Getting all instances of CCM_Application (Microsoft.SoftwareCenter.Client.Data.Shared.WmiDataConnectorShared at GetAllApplicationsWithType)"; + + #[test] + fn published_record_maps_to_every_documented_field() { + let fields = parse_record_fields(PUBLISHED_RECORD).expect("published record must parse"); + + assert_eq!(fields.timestamp.raw_text, "2024-11-15T16:50:07.2850341Z"); + assert_eq!(fields.timestamp.kind, CompanyPortalTimestampKind::Utc); + assert_eq!( + fields.timestamp.normalized_utc.as_deref(), + Some("2024-11-15T16:50:07.285034100Z") + ); + assert_eq!(fields.severity.raw_text, "INFO"); + assert_eq!( + fields.severity.level, + CompanyPortalSeverityLevel::Information + ); + assert_eq!(fields.category, "Event"); + assert_eq!(fields.scenario, "None"); + assert_eq!(fields.sequence, 0); + assert_eq!(fields.activity_id, "1487dc30-3bb0-46bf-98ee-76771bd9953e"); + assert_eq!(fields.app_version.raw_text, "12-0-0"); + assert_eq!( + fields.app_version.support, + CompanyPortalGrammarSupport::Validated + ); + assert_eq!( + fields.component.as_deref(), + Some("Configuration Manager Trace Listener") + ); + } + + #[test] + fn message_keeps_nested_configmgr_trace_text_verbatim() { + let fields = parse_record_fields(PUBLISHED_RECORD).expect("published record must parse"); + + // The nested day-first date, the SCClient prefix, the internal run of + // spaces, and the trailing (Type at Method) all survive untouched. + assert_eq!( + fields.message, + "[Configuration Manager Trace Listener] 15/11/2024 16:50:07: SCClient Information: 1: Getting all instances of CCM_Application (Microsoft.SoftwareCenter.Client.Data.Shared.WmiDataConnectorShared at GetAllApplicationsWithType)" + ); + assert!(PUBLISHED_RECORD.ends_with(&fields.message)); + } + + #[test] + fn multi_word_category_and_scenario_survive_the_split() { + let line = "2024-11-15T16:50:07.2850341Z INFO App Install Device Sync 17 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 ready"; + let fields = parse_record_fields(line).expect("multi-word columns must parse"); + + assert_eq!(fields.category, "App Install"); + assert_eq!(fields.scenario, "Device Sync"); + assert_eq!(fields.sequence, 17); + assert_eq!(fields.message, "ready"); + } + + #[test] + fn record_with_empty_message_parses() { + let line = "2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0"; + let fields = parse_record_fields(line).expect("empty message must still parse"); + + assert_eq!(fields.message, ""); + assert_eq!(fields.component, None); + } + + #[test] + fn unknown_app_version_is_experimental_not_rejected() { + let line = "2026-02-03T09:15:00.1230000Z WARNING Event None 4 1487dc30-3bb0-46bf-98ee-76771bd9953e 13-4-2 catalog refresh deferred"; + let fields = parse_record_fields(line).expect("unknown app version must still parse"); + + assert_eq!( + fields.app_version.support, + CompanyPortalGrammarSupport::Experimental + ); + assert_eq!(fields.app_version.triple.major, 13); + } + + #[test] + fn unknown_severity_token_is_preserved_rather_than_defaulted() { + let line = "2024-11-15T16:50:07.2850341Z NOTICE Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 something happened"; + let fields = parse_record_fields(line).expect("unknown severity must not fail the record"); + + assert_eq!(fields.severity.raw_text, "NOTICE"); + assert_eq!(fields.severity.level, CompanyPortalSeverityLevel::Unknown); + } + + #[test] + fn invalid_timestamp_does_not_parse_as_a_record() { + let line = "2024-13-45T99:99:99.0000000Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 impossible instant"; + assert_eq!(parse_record_fields(line), None); + // It still *looks* like a record start, so the caller reports it as a + // malformed record rather than folding it into the record above. + assert!(looks_like_record_start(line)); + } + + #[test] + fn missing_fractional_digits_or_zone_does_not_parse() { + assert!(!has_round_trip_shape("2024-11-15T16:50:07Z")); + assert!(!has_round_trip_shape("2024-11-15T16:50:07.285Z")); + assert!(!has_round_trip_shape("2024-11-15T16:50:07.2850341")); + assert!(!has_round_trip_shape("2024-11-15T16:50:07.2850341+00:00")); + assert!(has_round_trip_shape("2024-11-15T16:50:07.2850341Z")); + } + + #[test] + fn non_guid_activity_field_does_not_parse() { + let line = "2024-11-15T16:50:07.2850341Z INFO Event None 0 not-a-guid-at-all-not-a-guid-at-all 12-0-0 message"; + assert_eq!(parse_record_fields(line), None); + } + + #[test] + fn dotted_version_field_does_not_parse() { + let line = "2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12.0.0 message"; + assert_eq!(parse_record_fields(line), None); + } + + #[test] + fn single_spaced_line_does_not_parse() { + // Fields are column-aligned; a single-spaced line is a different format. + let line = "2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 message"; + assert_eq!(parse_record_fields(line), None); + } + + #[test] + fn leading_component_requires_a_balanced_first_bracket() { + assert_eq!(leading_component("[Sync] started"), Some("Sync")); + assert_eq!(leading_component("[Sync]"), Some("Sync")); + assert_eq!(leading_component("started [Sync]"), None); + assert_eq!(leading_component("[unterminated"), None); + assert_eq!(leading_component("[] empty"), None); + assert_eq!(leading_component("[[nested]] value"), None); + } + + #[test] + fn record_start_shape_rejects_other_timestamp_styles() { + assert!(looks_like_record_start( + "2024-11-15T16:50:07.2850341Z INFO" + )); + assert!(!looks_like_record_start("2024-11-15 16:50:07 message")); + assert!(!looks_like_record_start(" at Microsoft.Foo.Bar()")); + assert!(!looks_like_record_start("")); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/mod.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/mod.rs new file mode 100644 index 000000000..cda2bb069 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/mod.rs @@ -0,0 +1,53 @@ +//! Company Portal Windows LocalState application logs. +//! +//! Reads `%LOCALAPPDATA%\Packages\Microsoft.CompanyPortal_8wekyb3d8bbwe\LocalState\Log_.log` +//! and the sibling `Log._.log` bridge logs, which are written by +//! the same logger and share the record grammar. +//! +//! # Evidence basis and its limitation +//! +//! Microsoft documents the path and the `Log_.log` file pattern but not the +//! record grammar. Exactly **one** verbatim record has ever been published, from +//! Company Portal app version `12-0-0`. The grammar in [`grammar`] is derived +//! from that record and is therefore version-scoped from the start: +//! +//! - records are read with [`CompanyPortalGrammarVersion::V1`]; +//! - a record whose app version is outside the validated set still parses with +//! `V1` but downgrades the document to +//! [`CompanyPortalGrammarSupport::Experimental`] and +//! [`CompanyPortalConfidence::Low`]; +//! - confidence never reaches `High`, because that would require a second app +//! version captured from a real device. +//! +//! Encoding, newline style, rotation ordering, the full severity vocabulary, +//! and whether payloads genuinely span lines are all unproven from public +//! evidence. Each is handled defensively rather than assumed; see [`framing`] +//! for the continuation rule and [`detect`] for the rotation-index handling. +//! +//! # Two projections +//! +//! - [`parse_lines`] produces `LogEntry` records for the log viewer. Local +//! rendering, never redacted. +//! - [`parse_log_document`] produces the canonical evidence document and is +//! **redacted by default**; +//! [`parse_log_document_preserving_local_values`] is the explicit opt-out. +//! +//! There is deliberately no semantic phase/outcome classification: none of the +//! Company Portal workflows (sign-in, enrollment, catalog, compliance, sync, +//! device actions, support) is proven by the available evidence, so unknown +//! messages stay ordinary parsed log records. + +mod detect; +mod document; +mod entries; +mod framing; +mod grammar; +mod models; +mod redaction; + +pub use detect::*; +pub use document::*; +pub use entries::*; +pub use grammar::*; +pub use models::*; +pub use redaction::*; diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/models.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/models.rs new file mode 100644 index 000000000..ffaff938e --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/models.rs @@ -0,0 +1,228 @@ +//! Wire types for the Company Portal Windows `Log_.log` evidence document. +//! +//! Serde recipe follows the ESP module: `rename_all = "camelCase"`, no +//! `skip_serializing_if`, determinism from declaration order. Every type that +//! can fail to parse keeps the original text so nothing observed is lost. + +use serde::{Deserialize, Serialize}; + +/// Wire schema version of [`CompanyPortalLogDocument`]. +/// +/// Bump only for a breaking change to the document shape. The *grammar* the +/// records were parsed with is tracked separately by +/// [`CompanyPortalGrammarVersion`], because a new app-version grammar does not +/// have to change the document shape. +pub const COMPANY_PORTAL_WINDOWS_LOGS_SCHEMA_VERSION: u32 = 1; + +/// Version of the record field grammar used to read a file. +/// +/// Versioned from the start: only one Company Portal app version has ever had +/// a verbatim record published, so a second observed layout must be able to +/// arrive as `V2` rather than silently reinterpreting `V1` records. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum CompanyPortalGrammarVersion { + /// Field layout observed in app version `12-0-0`. + V1, +} + +/// Whether the grammar was applied to an app version it was actually derived +/// from. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum CompanyPortalGrammarSupport { + /// The record's app-version field is one the grammar was derived from. + Validated, + /// The record layout matched but the app version is outside the validated + /// set. The grammar is applied unchanged and the result is downgraded + /// rather than guessed at. + Experimental, +} + +/// Confidence in the derived record fields. +/// +/// Deliberately capped at [`CompanyPortalConfidence::Medium`]: raising it to +/// `High` requires a second Company Portal app version captured from a real +/// device, which does not exist yet. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum CompanyPortalConfidence { + Low, + Medium, + High, +} + +/// Severity level mapped from the record's dedicated severity field. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum CompanyPortalSeverityLevel { + Verbose, + Information, + Warning, + Error, + Critical, + /// The field was present but its token is not in the known vocabulary. The + /// token itself is kept in [`CompanyPortalSeverity::raw_text`]. + Unknown, +} + +/// The record's dedicated severity field, kept losslessly. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompanyPortalSeverity { + pub raw_text: String, + pub level: CompanyPortalSeverityLevel, +} + +/// How far a record timestamp could be resolved. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum CompanyPortalTimestampKind { + /// Resolved to an absolute UTC instant. + Utc, + /// The field had the right shape but is not a real instant. + Invalid, +} + +/// A record timestamp. `raw_text` is always the field exactly as written. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompanyPortalTimestamp { + pub raw_text: String, + pub normalized_utc: Option, + pub kind: CompanyPortalTimestampKind, +} + +/// A dash-separated Company Portal app version triple, e.g. `12-0-0`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompanyPortalVersionTriple { + pub major: u32, + pub minor: u32, + pub patch: u32, +} + +/// The record's app-version field plus whether the grammar was derived from it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompanyPortalAppVersion { + pub raw_text: String, + pub triple: CompanyPortalVersionTriple, + pub support: CompanyPortalGrammarSupport, +} + +/// Which LocalState log a file is. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum CompanyPortalLogFileKind { + /// `Log_.log` — the main Company Portal app log. + App, + /// `Log._.log` — a bridge log written by the same logger. + Bridge, + /// The name does not follow either LocalState pattern. + Unrecognized, +} + +/// Identity of the file a document was built from. +/// +/// Only the file name is retained. The full path contains the user profile +/// directory, which is privacy-sensitive, and the LocalState folder is already +/// implied by the artifact family. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompanyPortalLogFileIdentity { + pub file_name: String, + pub kind: CompanyPortalLogFileKind, + /// Bridge name for [`CompanyPortalLogFileKind::Bridge`], e.g. + /// `ConfigurationManagerBridge`. + pub bridge_name: Option, + /// The `` in `Log_.log`. Preserved so rotated members stay distinct; + /// whether `1` is the newest or the oldest member is not established by any + /// published evidence, so members are never reordered or deduplicated. + pub rotation_index: Option, +} + +/// Per-record parse outcome. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum CompanyPortalParseState { + /// Fields 1-7 validated against the grammar. + Parsed, + /// The line began a record but failed validation. Its text is preserved + /// verbatim in [`CompanyPortalLogRecord::raw_text`]. + Malformed, + /// Text that belongs to no record — a truncated leading fragment. + Orphaned, +} + +/// Coverage status for an artifact the document tried to account for. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum CompanyPortalCoverageStatus { + Available, + ParseFailed, + Unsupported, +} + +/// A named gap, so that "we did not read this" is never mistaken for "this did +/// not happen". +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompanyPortalCoverage { + pub artifact_id: String, + pub family: String, + pub status: CompanyPortalCoverageStatus, + pub detail: Option, +} + +/// One Company Portal log record. +/// +/// A record is one header line plus any continuation lines that followed it. +/// Everything the grammar did not claim stays in `message`, and `raw_text` +/// always holds the record's original text. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompanyPortalLogRecord { + /// Stable id: `companyPortalLog||`. + pub record_id: String, + /// 1-based line number of the record's first physical line. + pub line_number: u32, + pub parse_state: CompanyPortalParseState, + pub timestamp: Option, + pub severity: Option, + /// Field 3 — record category/kind token. + pub category: Option, + /// Field 4 — scenario/context name. The literal `None` is how .NET renders + /// a null scenario; it is kept as the string it is, not turned into an + /// absent field. + pub scenario: Option, + /// Field 5 — monotonic sequence value. Not proven to be a thread id, so it + /// is never mapped onto a thread column. + pub sequence: Option, + /// Field 6 — correlation/activity identifier. + pub activity_id: Option, + pub app_version: Option, + /// Leading `[Component Name]` of the message, when the message opens with a + /// balanced bracket. The bracket is *not* removed from `message`. + pub component: Option, + /// Field 8 onward, verbatim, including any nested legacy ConfigMgr trace + /// text and its own day-first date. + pub message: String, + /// The record's original text, including continuation lines. + pub raw_text: String, +} + +/// A parsed Company Portal log file. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CompanyPortalLogDocument { + pub schema_version: u32, + pub grammar_version: CompanyPortalGrammarVersion, + pub grammar_support: CompanyPortalGrammarSupport, + pub confidence: CompanyPortalConfidence, + /// `true` when sensitive values were redacted out of this document. + pub redacted: bool, + pub file: CompanyPortalLogFileIdentity, + pub records: Vec, + pub coverage: Vec, +} diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/redaction.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/redaction.rs new file mode 100644 index 000000000..9ef3c34ef --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/redaction.rs @@ -0,0 +1,95 @@ +//! Redaction projection for the Company Portal Windows log evidence document. +//! +//! The rule table itself lives in `esp::redaction` and is reused verbatim +//! through [`crate::esp::redact_text`]. It is evidence-agnostic — UPN/email, +//! user-profile paths, SIDs, secret-labelled values (`token`, `authorization`, +//! `tenantId`, `serialNumber`, `hardwareHash`, …), Azure storage credentials, +//! and IPv4/IPv6/MAC identifiers — and duplicating it here would let two rule +//! tables drift apart. This module only decides *which fields* of a Company +//! Portal document are free text. + +use crate::esp::redact_text; + +use super::models::CompanyPortalLogDocument; + +/// Return a safe copy/export projection without changing the input document. +/// +/// Every free-text field is run through the shared rule table; the file name, +/// activity id, app version, sequence, and timestamps are structural and are +/// left intact so records stay correlatable after redaction. +/// +/// Callers normally get this for free: `parse_log_document` applies it, and +/// only `parse_log_document_preserving_local_values` skips it. +pub fn redacted_export_projection(document: &CompanyPortalLogDocument) -> CompanyPortalLogDocument { + let mut safe = document.clone(); + safe.redacted = true; + + for record in &mut safe.records { + record.message = redact_text(&record.message); + record.raw_text = redact_text(&record.raw_text); + redact_optional(&mut record.component); + redact_optional(&mut record.category); + redact_optional(&mut record.scenario); + } + + for coverage in &mut safe.coverage { + redact_optional(&mut coverage.detail); + } + + safe +} + +fn redact_optional(value: &mut Option) { + if let Some(value) = value { + *value = redact_text(value); + } +} + +#[cfg(test)] +mod tests { + use super::super::document::parse_log_document_preserving_local_values; + use super::*; + + const PATH: &str = "C:/Users/adele.vance/AppData/Local/Packages/Microsoft.CompanyPortal_8wekyb3d8bbwe/LocalState/Log_1.log"; + + fn sample_document() -> CompanyPortalLogDocument { + let content = concat!( + "2024-11-15T16:50:07.2850341Z INFO Event None 0 ", + "1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 ", + "[SignIn] signed in adele.vance@contoso.onmicrosoft.com from 203.0.113.10\n", + ); + parse_log_document_preserving_local_values(PATH, content) + } + + #[test] + fn projection_does_not_mutate_the_input_document() { + let document = sample_document(); + let safe = redacted_export_projection(&document); + + assert!(!document.redacted); + assert!(safe.redacted); + assert!(document.records[0] + .message + .contains("adele.vance@contoso.onmicrosoft.com")); + } + + #[test] + fn projection_is_idempotent() { + let document = sample_document(); + let safe = redacted_export_projection(&document); + + assert_eq!(redacted_export_projection(&safe), safe); + } + + #[test] + fn structural_correlation_fields_survive_redaction() { + let safe = redacted_export_projection(&sample_document()); + + assert_eq!( + safe.records[0].activity_id.as_deref(), + Some("1487dc30-3bb0-46bf-98ee-76771bd9953e") + ); + assert_eq!(safe.records[0].component.as_deref(), Some("SignIn")); + assert_eq!(safe.file.file_name, "Log_1.log"); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/mod.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/mod.rs new file mode 100644 index 000000000..0f0372d02 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/mod.rs @@ -0,0 +1,3 @@ +//! Artifacts written by the `Microsoft.CompanyPortal_8wekyb3d8bbwe` package. + +pub mod logs; diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/mod.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/mod.rs new file mode 100644 index 000000000..3f125bbd1 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/mod.rs @@ -0,0 +1,3 @@ +//! Windows Company Portal artifacts. + +pub mod company_portal; diff --git a/crates/cmtraceopen-parser/src/models/log_entry.rs b/crates/cmtraceopen-parser/src/models/log_entry.rs index eb9f1f923..d5f27b90a 100644 --- a/crates/cmtraceopen-parser/src/models/log_entry.rs +++ b/crates/cmtraceopen-parser/src/models/log_entry.rs @@ -66,6 +66,7 @@ pub enum ParserKind { DnsDebug, DnsAudit, CmtLog, + CompanyPortal, } /// Concrete parser implementation currently used by the backend. @@ -89,6 +90,7 @@ pub enum ParserImplementation { DnsDebug, DnsAudit, CmtLog, + CompanyPortal, } /// How the backend arrived at the parser selection. diff --git a/crates/cmtraceopen-parser/src/parser/detect.rs b/crates/cmtraceopen-parser/src/parser/detect.rs index 0d0fddaee..03f7c1faf 100644 --- a/crates/cmtraceopen-parser/src/parser/detect.rs +++ b/crates/cmtraceopen-parser/src/parser/detect.rs @@ -20,6 +20,7 @@ use super::{ patchmypc_detection, psadt, reporting_events, secureboot_log, timestamped::{self, DateOrder}, }; +use crate::intune::portal::windows::company_portal::logs as company_portal_logs; use crate::models::log_entry::{ DateFieldOrder, LogFormat, ParseQuality, ParserImplementation, ParserKind, ParserProvenance, ParserSelectionInfo, ParserSpecialization, RecordFraming, @@ -262,6 +263,39 @@ impl ResolvedParser { ) } + /// Company Portal Windows LocalState log, confirmed on an app version the + /// record grammar was derived from. + pub fn company_portal() -> Self { + Self::new( + ParserKind::CompanyPortal, + ParserImplementation::CompanyPortal, + ParserProvenance::Dedicated, + ParseQuality::Structured, + RecordFraming::LogicalRecord, + DateOrder::default(), + None, + ) + } + + /// Company Portal Windows LocalState log whose records match the layout but + /// report an app version the grammar was not derived from. + /// + /// The records are still read with the only grammar that exists rather than + /// guessing a different one, but the selection is marked `Heuristic` so the + /// UI does not present it as a validated read. The parsed evidence document + /// carries the matching `Experimental` / low-confidence markers. + pub fn company_portal_experimental() -> Self { + Self::new( + ParserKind::CompanyPortal, + ParserImplementation::CompanyPortal, + ParserProvenance::Heuristic, + ParseQuality::Structured, + RecordFraming::LogicalRecord, + DateOrder::default(), + None, + ) + } + pub fn cmtlog() -> Self { Self::new( ParserKind::CmtLog, @@ -341,6 +375,7 @@ impl ResolvedParser { ParserImplementation::DnsDebug => LogFormat::DnsDebug, ParserImplementation::DnsAudit => LogFormat::DnsAudit, ParserImplementation::CmtLog => LogFormat::CmtLog, + ParserImplementation::CompanyPortal => LogFormat::Timestamped, } } @@ -458,7 +493,7 @@ pub fn detect_parser(path: &str, content: &str) -> ResolvedParser { let reporting_events_path_hint = path_lower.ends_with("reportingevents.log") || path_lower.contains("/softwaredistribution/reportingevents.log") || path_lower.contains("\\softwaredistribution\\reportingevents.log"); - let ime_file_name = path_lower.rsplit(['/', '\\']).next().unwrap_or(""); + let path_file_name = path_lower.rsplit(['/', '\\']).next().unwrap_or(""); let ime_path_hint = [ "agentexecutor", "appactionprocessor", @@ -468,7 +503,14 @@ pub fn detect_parser(path: &str, content: &str) -> ResolvedParser { "intunemanagementextension", ] .iter() - .any(|prefix| ime_file_name.starts_with(prefix)); + .any(|prefix| path_file_name.starts_with(prefix)); + + // `Log_.log` belongs to every UWP package that wants it, so this hint + // only nominates a candidate — the decision arm still requires records that + // carry a GUID activity id and a dash-separated app-version triple. + let company_portal_path_hint = + company_portal_logs::is_company_portal_log_file_name(path_file_name) + || path_lower.contains("microsoft.companyportal_8wekyb3d8bbwe"); let dhcp_path_hint = path_lower.contains("dhcpsrvlog") || path_lower.contains("dhcpv6srvlog") @@ -498,6 +540,8 @@ pub fn detect_parser(path: &str, content: &str) -> ResolvedParser { let mut secureboot_log_count = 0u32; let mut dns_debug_count = 0u32; let mut cmtlog_count = 0u32; + let mut company_portal_count = 0u32; + let mut company_portal_validated_count = 0u32; let mut timestamp_count = 0; let mut has_day_first = false; @@ -534,6 +578,12 @@ pub fn detect_parser(path: &str, content: &str) -> ResolvedParser { } else if secureboot_log::matches_secureboot_log_record(line.trim()) { secureboot_log_count += 1; timestamp_count += 1; + } else if let Some(observation) = company_portal_logs::classify_line(line.trim_end()) { + company_portal_count += 1; + if observation.app_version_is_validated { + company_portal_validated_count += 1; + } + timestamp_count += 1; } else if dhcp::matches_dhcp_record(line.trim()) { dhcp_count += 1; } else if dns_debug::matches_dns_debug_record(line.trim()) { @@ -598,6 +648,14 @@ pub fn detect_parser(path: &str, content: &str) -> ResolvedParser { DateOrder::MonthFirst }; ResolvedParser::dns_debug(dns_date_order) + } else if (company_portal_path_hint && company_portal_count >= 1) || company_portal_count >= 2 { + // An app version outside the validated set still parses with the only + // grammar there is, but the selection says so. + if company_portal_validated_count > 0 { + ResolvedParser::company_portal() + } else { + ResolvedParser::company_portal_experimental() + } } else if (intune_macos_path_hint && intune_macos_count >= 1) || intune_macos_count >= 2 { ResolvedParser::intune_macos() } else if msi_count >= 2 { @@ -918,6 +976,103 @@ Message two $$<01-01-2024 08:00:01.000+000>"#; assert_eq!(detected.parse_quality, ParseQuality::Structured); } + /// `%LOCALAPPDATA%\Packages\Microsoft.CompanyPortal_8wekyb3d8bbwe\LocalState`. + const COMPANY_PORTAL_LOCALSTATE: &str = "C:/Users/adele.vance/AppData/Local/Packages/Microsoft.CompanyPortal_8wekyb3d8bbwe/LocalState"; + + #[test] + fn test_detect_company_portal_from_path_and_content() { + let content = "2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 [Configuration Manager Trace Listener] 15/11/2024 16:50:07: SCClient Information: 1: Getting all instances of CCM_Application\n\ + 2024-11-15T16:50:08.1120000Z ERROR Event None 1 4f2b18a9-6c3d-4e91-b8a7-0d5e2c9f7143 12-0-0 [App Install] request rejected"; + + let detected = detect_parser(&format!("{COMPANY_PORTAL_LOCALSTATE}/Log_1.log"), content); + let info = detected.to_info(); + + assert_eq!(detected.parser, ParserKind::CompanyPortal); + assert_eq!(detected.implementation, ParserImplementation::CompanyPortal); + assert_eq!(detected.provenance, ParserProvenance::Dedicated); + assert_eq!(detected.parse_quality, ParseQuality::Structured); + assert_eq!(detected.record_framing, RecordFraming::LogicalRecord); + assert_eq!(detected.compatibility_format(), LogFormat::Timestamped); + assert_eq!(info.date_order, None); + assert_eq!(info.specialization, None); + } + + #[test] + fn test_detect_company_portal_bridge_log_shares_the_grammar() { + let content = "2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 [ConfigMgr Bridge] querying CCM_Application"; + + let detected = detect_parser( + &format!("{COMPANY_PORTAL_LOCALSTATE}/Log.ConfigurationManagerBridge_1.log"), + content, + ); + + assert_eq!(detected.parser, ParserKind::CompanyPortal); + } + + #[test] + fn test_detect_company_portal_from_content_without_path_hint() { + let content = "2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 [Sync] started\n\ + 2024-11-15T16:50:08.1120000Z INFO Event None 1 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 [Sync] complete"; + + let detected = detect_parser("C:/Temp/exported-portal-log.txt", content); + + assert_eq!(detected.parser, ParserKind::CompanyPortal); + } + + #[test] + fn test_detect_company_portal_downgrades_unknown_app_version() { + let content = "2026-02-03T09:15:00.1230000Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 13-4-2 [Sync] started"; + + let detected = detect_parser(&format!("{COMPANY_PORTAL_LOCALSTATE}/Log_1.log"), content); + + assert_eq!(detected.parser, ParserKind::CompanyPortal); + assert_eq!(detected.provenance, ParserProvenance::Heuristic); + } + + #[test] + fn test_unrelated_uwp_log_file_does_not_false_positive() { + // Same file name, same package layout, aligned columns, ISO timestamps + // — and still refused, because field 6 is not a GUID and field 7 is not + // a dash-separated version triple. + let content = "2026-05-04T08:12:31.4410000Z INFO Startup Foreground 0 Shell 1.2.3 session started\n\ + 2026-05-04T08:12:32.0020000Z WARN Startup Foreground 1 Shell 1.2.3 tile refresh deferred"; + + let detected = detect_parser( + "C:/Users/adele.vance/AppData/Local/Packages/Contoso.SampleApp_1a2b3c4d5e6f7/LocalState/Log_1.log", + content, + ); + + assert_eq!(detected.parser, ParserKind::Timestamped); + assert_eq!( + detected.implementation, + ParserImplementation::GenericTimestamped + ); + } + + #[test] + fn test_generic_timestamped_in_company_portal_path_does_not_false_positive() { + let content = "2026-05-04 08:12:31.441 Service started\n\ + 2026-05-04 08:12:32.002 Service ready"; + + let detected = detect_parser(&format!("{COMPANY_PORTAL_LOCALSTATE}/Log_1.log"), content); + + assert_eq!(detected.parser, ParserKind::Timestamped); + } + + #[test] + fn test_ime_ccm_log_inside_localstate_stays_ccm() { + let content = r#" +"#; + + let detected = detect_parser( + &format!("{COMPANY_PORTAL_LOCALSTATE}/IntuneManagementExtension.log"), + content, + ); + + assert_eq!(detected.parser, ParserKind::Ccm); + assert_eq!(detected.specialization, Some(ParserSpecialization::Ime)); + } + #[test] fn test_generic_timestamped_with_dns_in_path_does_not_false_positive() { let content = "2026-04-11 15:29:17 DNS resolution started\n\ diff --git a/crates/cmtraceopen-parser/src/parser/mod.rs b/crates/cmtraceopen-parser/src/parser/mod.rs index 6f0bd20f4..2573f9bdd 100644 --- a/crates/cmtraceopen-parser/src/parser/mod.rs +++ b/crates/cmtraceopen-parser/src/parser/mod.rs @@ -127,6 +127,9 @@ pub fn parse_lines_with_selection( crate::models::log_entry::ParserImplementation::CmtLog => { cmtlog::parse_lines(lines, file_path) } + crate::models::log_entry::ParserImplementation::CompanyPortal => { + crate::intune::portal::windows::company_portal::logs::parse_lines(lines, file_path) + } crate::models::log_entry::ParserImplementation::GenericTimestamped => { match selection.parser { crate::models::log_entry::ParserKind::Cbs => cbs::parse_lines(lines, file_path), diff --git a/crates/cmtraceopen-parser/tests/company_portal_windows_logs.rs b/crates/cmtraceopen-parser/tests/company_portal_windows_logs.rs new file mode 100644 index 000000000..ced9ef6fa --- /dev/null +++ b/crates/cmtraceopen-parser/tests/company_portal_windows_logs.rs @@ -0,0 +1,695 @@ +//! Company Portal Windows LocalState log contracts — issue #366. +//! +//! Fixtures under `tests/fixtures/intune/portal/windows/logs///` +//! are entirely synthetic. Every UPN, tenant id, serial, token, GUID, and host +//! name in them was authored for this suite; no user-submitted diagnostic is +//! committed to this repository. +//! +//! The version directory is load-bearing. `v12-0-0` is the only Company Portal +//! app version with a published verbatim record, so it holds the scenarios the +//! grammar was derived from; `v13-4-2` exercises the downgrade path for a +//! version the grammar was *not* derived from. +//! +//! Encoding fixtures are byte-sensitive (UTF-8 BOM, CRLF, UTF-16LE) and are +//! loaded with `include_bytes!` so the crate's own decoding boundary is what is +//! under test. + +use cmtraceopen_parser::intune::portal::windows::company_portal::logs::*; +use cmtraceopen_parser::models::log_entry::{ + LogFormat, ParseQuality, ParserImplementation, ParserKind, ParserProvenance, RecordFraming, + Severity, +}; +use cmtraceopen_parser::parser::{decode_bytes, detect_encoding, parse_content, FileEncoding}; + +/// `%LOCALAPPDATA%\Packages\Microsoft.CompanyPortal_8wekyb3d8bbwe\LocalState`. +const LOCAL_STATE: &str = + "C:/Users/adele.vance/AppData/Local/Packages/Microsoft.CompanyPortal_8wekyb3d8bbwe/LocalState"; + +fn local_state_path(file_name: &str) -> String { + format!("{LOCAL_STATE}/{file_name}") +} + +/// Run a fixture through the full public pipeline: detection, then parsing. +fn parse_fixture( + file_name: &str, + content: &str, +) -> ( + cmtraceopen_parser::models::log_entry::ParseResult, + cmtraceopen_parser::parser::ResolvedParser, +) { + let path = local_state_path(file_name); + parse_content(content, &path, content.len() as u64) +} + +fn document(file_name: &str, content: &str) -> CompanyPortalLogDocument { + parse_log_document(&local_state_path(file_name), content) +} + +// --------------------------------------------------------------------------- +// 1. information / warning / error records +// --------------------------------------------------------------------------- + +const SEVERITY_LEVELS: &str = + include_str!("fixtures/intune/portal/windows/logs/v12-0-0/severity-levels/Log_1.log"); + +#[test] +fn portal_logs_severity_fixture_covers_every_documented_level() { + let (result, _) = parse_fixture("Log_1.log", SEVERITY_LEVELS); + assert_eq!(result.entries.len(), 6, "severity fixture rows"); + assert_eq!(result.parse_errors, 0); + + let severities: Vec = result.entries.iter().map(|entry| entry.severity).collect(); + assert_eq!( + severities, + vec![ + Severity::Info, // INFO + Severity::Warning, // WARNING + Severity::Error, // ERROR + Severity::Info, // VERBOSE + Severity::Info, // INFO whose message says "failed" + Severity::Error, // NOTICE — unknown token, inferred from "error" + ] + ); +} + +#[test] +fn portal_logs_dedicated_severity_beats_keyword_inference() { + let (result, _) = parse_fixture("Log_1.log", SEVERITY_LEVELS); + + let entry = &result.entries[4]; + assert!(entry.message.contains("failed")); + assert_eq!( + entry.severity, + Severity::Info, + "the record's own severity field must win over the word 'failed'" + ); +} + +#[test] +fn portal_logs_unknown_severity_token_is_preserved_in_the_document() { + let document = document("Log_1.log", SEVERITY_LEVELS); + let severity = document.records[5] + .severity + .as_ref() + .expect("record must carry its severity field"); + + assert_eq!(severity.raw_text, "NOTICE"); + assert_eq!(severity.level, CompanyPortalSeverityLevel::Unknown); +} + +// --------------------------------------------------------------------------- +// 2. multiline continuation +// --------------------------------------------------------------------------- + +const MULTILINE: &str = + include_str!("fixtures/intune/portal/windows/logs/v12-0-0/multiline-continuation/Log_1.log"); + +#[test] +fn portal_logs_continuation_lines_join_the_record_above() { + let (result, selection) = parse_fixture("Log_1.log", MULTILINE); + + assert_eq!(selection.record_framing, RecordFraming::LogicalRecord); + assert_eq!(result.entries.len(), 3, "three records, six physical lines"); + assert_eq!(result.parse_errors, 0); + + let failure = &result.entries[1]; + assert_eq!(failure.line_number, 2, "record keeps its first line number"); + assert!(failure.message.contains("HttpRequestException")); + assert!(failure + .message + .contains(" at Contoso.Sample.PortalClient.CatalogClient.GetAppsAsync()")); + assert_eq!(result.entries[2].line_number, 6); +} + +#[test] +fn portal_logs_continuation_text_is_byte_identical_to_the_source() { + let document = document("Log_1.log", MULTILINE); + let record = &document.records[1]; + + for line in MULTILINE.lines().skip(2).take(3) { + assert!( + record.raw_text.contains(line.trim_end()), + "continuation line must survive verbatim: {line}" + ); + } +} + +// --------------------------------------------------------------------------- +// 3. current plus rotated files +// --------------------------------------------------------------------------- + +const ROTATION_CURRENT: &str = + include_str!("fixtures/intune/portal/windows/logs/v12-0-0/rotation/Log_1.log"); +const ROTATION_ROLLED: &str = + include_str!("fixtures/intune/portal/windows/logs/v12-0-0/rotation/Log_2.log"); + +#[test] +fn portal_logs_rotation_members_keep_distinct_identities() { + let current = document("Log_1.log", ROTATION_CURRENT); + let rolled = document("Log_2.log", ROTATION_ROLLED); + + assert_eq!(current.file.kind, CompanyPortalLogFileKind::App); + assert_eq!(current.file.rotation_index, Some(1)); + assert_eq!(rolled.file.rotation_index, Some(2)); + assert_ne!(current.records[0].record_id, rolled.records[0].record_id); +} + +#[test] +fn portal_logs_repeated_record_across_rotation_is_not_deduplicated() { + let current = document("Log_1.log", ROTATION_CURRENT); + let rolled = document("Log_2.log", ROTATION_ROLLED); + + let boundary = "[Sync] rollover boundary record"; + assert!(current + .records + .iter() + .any(|record| record.message.contains(boundary))); + assert!(rolled + .records + .iter() + .any(|record| record.message.contains(boundary))); + assert_eq!(current.records.len(), 2); + assert_eq!(rolled.records.len(), 2); +} + +#[test] +fn portal_logs_bridge_file_identity_is_preserved() { + let bridge = document("Log.ConfigurationManagerBridge_1.log", ROTATION_CURRENT); + + assert_eq!(bridge.file.kind, CompanyPortalLogFileKind::Bridge); + assert_eq!( + bridge.file.bridge_name.as_deref(), + Some("ConfigurationManagerBridge") + ); +} + +// --------------------------------------------------------------------------- +// 4. same timestamp, distinct activity ids +// --------------------------------------------------------------------------- + +const SAME_TIMESTAMP: &str = include_str!( + "fixtures/intune/portal/windows/logs/v12-0-0/same-timestamp-distinct-activity/Log_1.log" +); + +#[test] +fn portal_logs_identical_timestamps_stay_separate_activities() { + let document = document("Log_1.log", SAME_TIMESTAMP); + assert_eq!(document.records.len(), 2); + + let first = &document.records[0]; + let second = &document.records[1]; + + assert_eq!( + first.timestamp.as_ref().map(|ts| ts.raw_text.as_str()), + second.timestamp.as_ref().map(|ts| ts.raw_text.as_str()) + ); + assert_ne!(first.activity_id, second.activity_id); + assert_ne!(first.record_id, second.record_id); + assert_eq!(first.scenario.as_deref(), Some("SignIn")); + assert_eq!(second.scenario.as_deref(), Some("DeviceSync")); +} + +// --------------------------------------------------------------------------- +// 5. known and unknown code tokens +// --------------------------------------------------------------------------- + +const CODE_TOKENS: &str = + include_str!("fixtures/intune/portal/windows/logs/v12-0-0/code-tokens/Log_1.log"); + +#[test] +fn portal_logs_known_and_unknown_code_tokens_are_both_preserved() { + let (result, _) = parse_fixture("Log_1.log", CODE_TOKENS); + assert_eq!(result.entries.len(), 2); + + assert!(result.entries[0].message.contains("0x80070005")); + assert!(result.entries[1].message.contains("0x0ABCDEF1")); + + // A known code gains a lookup span; an unknown code is left as text rather + // than being resolved to a guess. + assert_eq!(result.entries[0].error_code_spans.len(), 1); + assert_eq!(result.entries[0].error_code_spans[0].code_hex, "0x80070005"); + assert!(result.entries[1].error_code_spans.is_empty()); +} + +// --------------------------------------------------------------------------- +// 6. invalid timestamp +// --------------------------------------------------------------------------- + +const INVALID_TIMESTAMP: &str = + include_str!("fixtures/intune/portal/windows/logs/v12-0-0/invalid-timestamp/Log_1.log"); + +#[test] +fn portal_logs_invalid_timestamp_is_preserved_as_a_parse_error() { + let (result, _) = parse_fixture("Log_1.log", INVALID_TIMESTAMP); + + assert_eq!(result.entries.len(), 3); + assert_eq!(result.parse_errors, 1); + + let broken = &result.entries[1]; + assert_eq!(broken.message, INVALID_TIMESTAMP.lines().nth(1).unwrap()); + assert_eq!(broken.format, LogFormat::Plain); + assert!(broken.timestamp.is_none()); + assert!(broken.timestamp_display.is_none()); + + // The records either side of it still parse. + assert!(result.entries[0].timestamp.is_some()); + assert!(result.entries[2].timestamp.is_some()); +} + +#[test] +fn portal_logs_invalid_timestamp_becomes_coverage_not_absence() { + let document = document("Log_1.log", INVALID_TIMESTAMP); + + assert_eq!( + document.records[1].parse_state, + CompanyPortalParseState::Malformed + ); + assert_eq!( + document.coverage[0].status, + CompanyPortalCoverageStatus::ParseFailed + ); + assert_eq!( + document.coverage[0].artifact_id, + "companyPortal.windows.logs" + ); +} + +// --------------------------------------------------------------------------- +// 7. truncated first and last records +// --------------------------------------------------------------------------- + +const TRUNCATED: &str = + include_str!("fixtures/intune/portal/windows/logs/v12-0-0/truncated-boundaries/Log_1.log"); + +#[test] +fn portal_logs_truncated_boundaries_are_preserved_losslessly() { + let document = document("Log_1.log", TRUNCATED); + assert_eq!(document.records.len(), 3); + + // Leading fragment of a rotated file — no record ever started it. + assert_eq!( + document.records[0].parse_state, + CompanyPortalParseState::Orphaned + ); + assert_eq!( + document.records[0].raw_text, + "alog client because the previous file reached its size limit)" + ); + + assert_eq!( + document.records[1].parse_state, + CompanyPortalParseState::Parsed + ); + + // Trailing record cut off mid-activity-id. + assert_eq!( + document.records[2].parse_state, + CompanyPortalParseState::Malformed + ); + assert_eq!( + document.records[2].raw_text, + TRUNCATED.lines().nth(2).unwrap() + ); +} + +#[test] +fn portal_logs_truncated_boundaries_report_two_parse_errors() { + let (result, _) = parse_fixture("Log_1.log", TRUNCATED); + assert_eq!(result.parse_errors, 2); + assert_eq!(result.entries.len(), 3); +} + +// --------------------------------------------------------------------------- +// 8. malformed structural token +// --------------------------------------------------------------------------- + +const MALFORMED_TOKEN: &str = include_str!( + "fixtures/intune/portal/windows/logs/v12-0-0/malformed-structural-token/Log_1.log" +); + +#[test] +fn portal_logs_malformed_structural_tokens_do_not_produce_derived_fields() { + let document = document("Log_1.log", MALFORMED_TOKEN); + assert_eq!(document.records.len(), 3); + + // A 35-character activity id and a dotted version are both structural + // failures; neither may yield a half-parsed record. + for index in [1usize, 2usize] { + let record = &document.records[index]; + assert_eq!( + record.parse_state, + CompanyPortalParseState::Malformed, + "record {index}" + ); + assert!(record.activity_id.is_none(), "record {index}"); + assert!(record.app_version.is_none(), "record {index}"); + assert!(record.severity.is_none(), "record {index}"); + assert_eq!( + record.raw_text, + MALFORMED_TOKEN.lines().nth(index).unwrap(), + "record {index}" + ); + } +} + +// --------------------------------------------------------------------------- +// 9. encodings: UTF-8 BOM, UTF-8 no BOM, UTF-16LE +// --------------------------------------------------------------------------- + +const UTF8_BOM: &[u8] = + include_bytes!("fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf8-bom/Log_1.log"); +const UTF8_NO_BOM: &[u8] = + include_bytes!("fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf8-nobom/Log_1.log"); +const UTF16_LE: &[u8] = + include_bytes!("fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf16le/Log_1.log"); + +fn decode(bytes: &[u8]) -> String { + let encoding = detect_encoding(bytes); + decode_bytes(bytes, encoding).expect("fixture must decode") +} + +#[test] +fn portal_logs_encoding_fixtures_carry_the_bytes_they_claim() { + assert_eq!(&UTF8_BOM[..3], &[0xEF, 0xBB, 0xBF]); + assert_ne!(&UTF8_NO_BOM[..3], &[0xEF, 0xBB, 0xBF]); + assert_eq!(&UTF16_LE[..2], &[0xFF, 0xFE]); + assert_eq!(detect_encoding(UTF8_BOM), FileEncoding::Utf8); + assert_eq!(detect_encoding(UTF8_NO_BOM), FileEncoding::Utf8); + assert_eq!(detect_encoding(UTF16_LE), FileEncoding::Utf16Le); +} + +#[test] +fn portal_logs_are_detected_and_parsed_identically_across_encodings() { + for (label, bytes) in [ + ("utf-8 with BOM", UTF8_BOM), + ("utf-8 without BOM", UTF8_NO_BOM), + ("utf-16le", UTF16_LE), + ] { + let content = decode(bytes); + let (result, selection) = parse_fixture("Log_1.log", &content); + + assert_eq!(selection.parser, ParserKind::CompanyPortal, "{label}"); + assert_eq!(result.parse_errors, 0, "{label}"); + assert_eq!(result.entries.len(), 2, "{label}"); + assert!( + result.entries[0].message.contains("resumé"), + "non-ASCII text must survive: {label}" + ); + assert_eq!( + result.entries[0].timestamp_display.as_deref(), + Some("2026-05-04 15:00:00.100"), + "{label}" + ); + } +} + +// --------------------------------------------------------------------------- +// 10 & 11. negatives — detection must not claim every Log_.log +// --------------------------------------------------------------------------- + +const NEGATIVE_UWP: &str = + include_str!("fixtures/intune/portal/windows/logs/v12-0-0/negative-unrelated-uwp/Log_1.log"); +const NEGATIVE_GENERIC: &str = include_str!( + "fixtures/intune/portal/windows/logs/v12-0-0/negative-generic-timestamped/Log_1.log" +); + +#[test] +fn portal_logs_unrelated_uwp_log_file_does_not_false_positive() { + // Same file name, same package layout, aligned columns and ISO instants — + // refused because field 6 is not a GUID and field 7 is not a version triple. + let path = "C:/Users/adele.vance/AppData/Local/Packages/Contoso.SampleApp_1a2b3c4d5e6f7/LocalState/Log_1.log"; + let (_, selection) = parse_content(NEGATIVE_UWP, path, NEGATIVE_UWP.len() as u64); + + assert_eq!(selection.parser, ParserKind::Timestamped); + assert_eq!( + selection.implementation, + ParserImplementation::GenericTimestamped + ); +} + +#[test] +fn portal_logs_unrelated_uwp_log_is_refused_even_inside_the_company_portal_folder() { + // The strongest form of the guarantee: the exact package path plus the + // exact file name still is not enough without confirming record structure. + let (_, selection) = parse_fixture("Log_1.log", NEGATIVE_UWP); + assert_eq!(selection.parser, ParserKind::Timestamped); +} + +#[test] +fn portal_logs_generic_timestamped_log_does_not_false_positive() { + let (_, selection) = parse_fixture("Log_1.log", NEGATIVE_GENERIC); + assert_eq!(selection.parser, ParserKind::Timestamped); +} + +#[test] +fn portal_logs_negative_fixtures_contain_no_confirmed_record() { + for content in [NEGATIVE_UWP, NEGATIVE_GENERIC] { + for line in content.lines() { + assert!( + !matches_company_portal_log_record(line.trim_end()), + "negative fixture line must not match: {line}" + ); + } + } +} + +// --------------------------------------------------------------------------- +// 12. redaction of synthetic UPN / tenant / device / token values +// --------------------------------------------------------------------------- + +const REDACTION: &str = + include_str!("fixtures/intune/portal/windows/logs/v12-0-0/redaction/Log_1.log"); + +/// Synthetic sensitive values planted in the redaction fixture. +const SENSITIVE_VALUES: [&str; 6] = [ + "adele.vance@contoso.onmicrosoft.com", + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.SYNTHETIC.SIGNATURE", + "SYNTHETIC-SERIAL-0F1E2D", + "adele.vance", + "203.0.113.10", +]; + +#[test] +fn portal_logs_document_is_redacted_by_default() { + let document = document("Log_1.log", REDACTION); + assert!(document.redacted); + + let json = serde_json::to_string(&document).expect("document must serialize"); + for value in SENSITIVE_VALUES { + assert!( + !json.contains(value), + "redacted document still contains {value}" + ); + } +} + +#[test] +fn portal_logs_local_projection_is_the_explicit_opt_out() { + let local = + parse_log_document_preserving_local_values(&local_state_path("Log_1.log"), REDACTION); + assert!(!local.redacted); + + let json = serde_json::to_string(&local).expect("document must serialize"); + assert!(json.contains("adele.vance@contoso.onmicrosoft.com")); + + // And the redacting projection over the same input drops everything. + let safe = redacted_export_projection(&local); + let safe_json = serde_json::to_string(&safe).expect("document must serialize"); + for value in SENSITIVE_VALUES { + assert!(!safe_json.contains(value), "{value}"); + } + assert!(!local.redacted, "projection must not mutate its input"); +} + +#[test] +fn portal_logs_redaction_keeps_records_correlatable() { + let document = document("Log_1.log", REDACTION); + + assert_eq!(document.records.len(), 4); + assert_eq!( + document.records[0].activity_id.as_deref(), + Some("1a2b3c4d-0001-4000-8000-000000000001") + ); + assert_eq!(document.records[0].component.as_deref(), Some("SignIn")); + assert_eq!( + document.records[0] + .timestamp + .as_ref() + .map(|ts| ts.raw_text.as_str()), + Some("2026-05-04T16:00:00.1000000Z") + ); +} + +#[test] +fn portal_logs_viewer_entries_are_not_redacted() { + // The viewer must show the file the user opened; redaction belongs to the + // evidence/export projection, not to local rendering. + let (result, _) = parse_fixture("Log_1.log", REDACTION); + assert!(result.entries[0] + .message + .contains("adele.vance@contoso.onmicrosoft.com")); +} + +// --------------------------------------------------------------------------- +// 13. unknown app-version downgrade +// --------------------------------------------------------------------------- + +const UNKNOWN_VERSION: &str = + include_str!("fixtures/intune/portal/windows/logs/v13-4-2/unknown-app-version/Log_1.log"); + +#[test] +fn portal_logs_unknown_app_version_downgrades_instead_of_guessing() { + let (result, selection) = parse_fixture("Log_1.log", UNKNOWN_VERSION); + + // Still claimed and still parsed with the only grammar there is … + assert_eq!(selection.parser, ParserKind::CompanyPortal); + assert_eq!( + selection.implementation, + ParserImplementation::CompanyPortal + ); + assert_eq!(result.parse_errors, 0); + assert_eq!(result.entries.len(), 2); + // … but the selection is heuristic rather than a validated read. + assert_eq!(selection.provenance, ParserProvenance::Heuristic); +} + +#[test] +fn portal_logs_unknown_app_version_is_named_in_the_document_and_its_coverage() { + let document = document("Log_1.log", UNKNOWN_VERSION); + + assert_eq!( + document.grammar_support, + CompanyPortalGrammarSupport::Experimental + ); + assert_eq!(document.confidence, CompanyPortalConfidence::Low); + assert_eq!(document.grammar_version, CompanyPortalGrammarVersion::V1); + + let app_version = document.records[0] + .app_version + .as_ref() + .expect("record must carry its app version"); + assert_eq!(app_version.raw_text, "13-4-2"); + assert_eq!(app_version.triple.major, 13); + assert_eq!( + app_version.support, + CompanyPortalGrammarSupport::Experimental + ); + + let gap = document + .coverage + .iter() + .find(|row| row.artifact_id == "companyPortal.windows.logs.grammar") + .expect("an unvalidated app version must be named in coverage"); + assert_eq!(gap.status, CompanyPortalCoverageStatus::Unsupported); +} + +#[test] +fn portal_logs_validated_app_version_is_medium_confidence_never_high() { + let document = document("Log_1.log", SEVERITY_LEVELS); + + assert_eq!( + document.grammar_support, + CompanyPortalGrammarSupport::Validated + ); + // One published app version is not enough for `High`. + assert_eq!(document.confidence, CompanyPortalConfidence::Medium); + assert!(document + .coverage + .iter() + .all(|row| row.artifact_id != "companyPortal.windows.logs.grammar")); +} + +// --------------------------------------------------------------------------- +// Cross-cutting contracts +// --------------------------------------------------------------------------- + +#[test] +fn portal_logs_selection_contract_is_stable() { + let (result, selection) = parse_fixture("Log_1.log", SEVERITY_LEVELS); + let info = selection.to_info(); + + assert_eq!(selection.parser, ParserKind::CompanyPortal); + assert_eq!( + selection.implementation, + ParserImplementation::CompanyPortal + ); + assert_eq!(selection.provenance, ParserProvenance::Dedicated); + assert_eq!(selection.parse_quality, ParseQuality::Structured); + assert_eq!(selection.record_framing, RecordFraming::LogicalRecord); + assert_eq!(selection.specialization, None); + assert_eq!(info.date_order, None); + assert_eq!(result.format_detected, LogFormat::Timestamped); +} + +#[test] +fn portal_logs_document_schema_version_is_pinned() { + let document = document("Log_1.log", SEVERITY_LEVELS); + let value = serde_json::to_value(&document).expect("document must serialize"); + + assert_eq!(value["schemaVersion"], 1); + assert_eq!(COMPANY_PORTAL_WINDOWS_LOGS_SCHEMA_VERSION, 1); + assert_eq!(value["grammarVersion"], "v1"); + assert_eq!(value["records"][0]["parseState"], "parsed"); + assert_eq!(value["file"]["kind"], "app"); +} + +#[test] +fn portal_logs_nested_configmgr_trace_text_is_never_reinterpreted() { + // The published record's message embeds a legacy ConfigMgr trace line whose + // date is day-first. It stays message text: the record timestamp is field 1 + // and the inner date is not touched. + let published = "2024-11-15T16:50:07.2850341Z INFO Event None 0 1487dc30-3bb0-46bf-98ee-76771bd9953e 12-0-0 [Configuration Manager Trace Listener] 15/11/2024 16:50:07: SCClient Information: 1: Getting all instances of CCM_Application (Microsoft.SoftwareCenter.Client.Data.Shared.WmiDataConnectorShared at GetAllApplicationsWithType)"; + let document = document("Log_1.log", published); + let record = &document.records[0]; + + assert_eq!( + record.timestamp.as_ref().map(|ts| ts.raw_text.as_str()), + Some("2024-11-15T16:50:07.2850341Z") + ); + assert!(record.message.contains("15/11/2024 16:50:07:")); + assert!(record.message.contains("SCClient Information: 1:")); + assert!(record.message.contains( + "CCM_Application (Microsoft.SoftwareCenter.Client.Data.Shared.WmiDataConnectorShared at GetAllApplicationsWithType)" + )); + assert!(published.ends_with(&record.message)); + assert_eq!( + record.component.as_deref(), + Some("Configuration Manager Trace Listener") + ); +} + +#[test] +fn portal_logs_every_fixture_line_survives_into_a_record() { + // Lossless-by-construction check across the whole matrix: no non-empty + // source line may be dropped. + for (label, content) in [ + ("severity-levels", SEVERITY_LEVELS), + ("multiline-continuation", MULTILINE), + ("rotation/Log_1", ROTATION_CURRENT), + ("rotation/Log_2", ROTATION_ROLLED), + ("same-timestamp", SAME_TIMESTAMP), + ("code-tokens", CODE_TOKENS), + ("invalid-timestamp", INVALID_TIMESTAMP), + ("truncated-boundaries", TRUNCATED), + ("malformed-structural-token", MALFORMED_TOKEN), + ("redaction", REDACTION), + ("unknown-app-version", UNKNOWN_VERSION), + ] { + let local = + parse_log_document_preserving_local_values(&local_state_path("Log_1.log"), content); + let joined = local + .records + .iter() + .map(|record| record.raw_text.as_str()) + .collect::>() + .join("\n"); + + for line in content.lines().filter(|line| !line.trim().is_empty()) { + assert!( + joined.contains(line.trim_end()), + "{label}: line was lost: {line}" + ); + } + } +} diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/code-tokens/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/code-tokens/Log_1.log new file mode 100644 index 000000000..d74cfc20c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/code-tokens/Log_1.log @@ -0,0 +1,2 @@ +2026-05-04T11:00:00.1000000Z ERROR Event AppInstall 0 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [App Install] the service returned 0x80070005 while reading the assignment +2026-05-04T11:00:01.2000000Z ERROR Event AppInstall 1 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [App Install] the service returned 0x0ABCDEF1 with no further detail diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf16le/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf16le/Log_1.log new file mode 100644 index 0000000000000000000000000000000000000000..f5ef90cadd1b786329022fd73ef49f6fa981074c GIT binary patch literal 632 zcmd6j%?<%U5QM+8#5?Q@5W^C2CvoAxi8!z}Nc?3j;*q@ss)r?9TpXk`Gu1US-PLuy zlqpfBMnPxQ?;(Aha6?S#u@}Wymc@WT(WXO-uE&;JG&vBnCVgWAz1)b6KiEJOv!9N! zluW1)5i<2!te+DKADs)-pD5GvemY;zsgg}(%jys5aZ>+?kuuX4Nzasp&`ivhMES() zJBm%2iyiCM_DFG`Db}BWquwS$4dFyLJNB$NlP9OX-~DtH{kxa)-}!l;-)8<7Kh5ct M9A3()RLfkt0iu#mX8-^I literal 0 HcmV?d00001 diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf8-bom/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf8-bom/Log_1.log new file mode 100644 index 000000000..7bbc9721e --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf8-bom/Log_1.log @@ -0,0 +1,2 @@ +2026-05-04T15:00:00.1000000Z INFO Event None 0 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [Sync] encoding probe with an accented word: resumé +2026-05-04T15:00:01.2000000Z INFO Event None 1 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [Sync] encoding probe complete diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf8-nobom/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf8-nobom/Log_1.log new file mode 100644 index 000000000..dd4b5c159 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/encoding-utf8-nobom/Log_1.log @@ -0,0 +1,2 @@ +2026-05-04T15:00:00.1000000Z INFO Event None 0 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [Sync] encoding probe with an accented word: resumé +2026-05-04T15:00:01.2000000Z INFO Event None 1 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [Sync] encoding probe complete diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/invalid-timestamp/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/invalid-timestamp/Log_1.log new file mode 100644 index 000000000..eb277f238 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/invalid-timestamp/Log_1.log @@ -0,0 +1,3 @@ +2026-05-04T12:00:00.1000000Z INFO Event None 0 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [Sync] started +2026-13-45T99:99:99.0000000Z ERROR Event None 1 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [Sync] impossible instant +2026-05-04T12:00:02.3000000Z INFO Event None 2 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [Sync] finished diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/malformed-structural-token/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/malformed-structural-token/Log_1.log new file mode 100644 index 000000000..e0da089b9 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/malformed-structural-token/Log_1.log @@ -0,0 +1,3 @@ +2026-05-04T14:00:00.1000000Z INFO Event None 0 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [Sync] healthy record +2026-05-04T14:00:01.2000000Z INFO Event None 1 00000000-0000-0000-0000-00000000000 12-0-0 [Sync] truncated activity id +2026-05-04T14:00:02.3000000Z INFO Event None 2 1a2b3c4d-0001-4000-8000-000000000001 12.0.0 [Sync] healthy record diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/multiline-continuation/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/multiline-continuation/Log_1.log new file mode 100644 index 000000000..c5eb526ca --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/multiline-continuation/Log_1.log @@ -0,0 +1,6 @@ +2026-05-04T09:01:00.1230000Z INFO Event AppInstall 0 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [App Install] requesting install for the sample line-of-business app +2026-05-04T09:01:04.9870000Z ERROR Event AppInstall 1 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [App Install] catalog call failed +System.Net.Http.HttpRequestException: Response status code does not indicate success: 403 (Forbidden). + at Contoso.Sample.PortalClient.CatalogClient.SendAsync() + at Contoso.Sample.PortalClient.CatalogClient.GetAppsAsync() +2026-05-04T09:01:05.0010000Z INFO Event AppInstall 2 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [App Install] install request abandoned diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/negative-generic-timestamped/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/negative-generic-timestamped/Log_1.log new file mode 100644 index 000000000..6007beead --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/negative-generic-timestamped/Log_1.log @@ -0,0 +1,3 @@ +2026-05-04 08:12:31.441 Service started +2026-05-04 08:12:32.002 Configuration loaded +2026-05-04 08:12:33.771 Service ready diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/negative-unrelated-uwp/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/negative-unrelated-uwp/Log_1.log new file mode 100644 index 000000000..52082db0f --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/negative-unrelated-uwp/Log_1.log @@ -0,0 +1,3 @@ +2026-05-04T08:12:31.4410000Z INFO Startup Foreground 0 Shell 1.2.3 session started +2026-05-04T08:12:32.0020000Z WARN Startup Foreground 1 Shell 1.2.3 tile refresh deferred +2026-05-04T08:12:33.7710000Z INFO Shutdown Background 2 Shell 1.2.3 session ended diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/redaction/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/redaction/Log_1.log new file mode 100644 index 000000000..4e56c8c25 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/redaction/Log_1.log @@ -0,0 +1,4 @@ +2026-05-04T16:00:00.1000000Z INFO Event SignIn 0 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [SignIn] signed in adele.vance@contoso.onmicrosoft.com tenantId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee +2026-05-04T16:00:01.2000000Z INFO Event SignIn 1 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [SignIn] token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.SYNTHETIC.SIGNATURE +2026-05-04T16:00:02.3000000Z INFO Event DeviceSync 2 1a2b3c4d-0001-4000-8000-000000000002 12-0-0 [Sync] deviceSerialNumber=SYNTHETIC-SERIAL-0F1E2D profile=C:\Users\adele.vance\AppData\Local\Packages +2026-05-04T16:00:03.4000000Z INFO Event DeviceSync 3 1a2b3c4d-0001-4000-8000-000000000002 12-0-0 [Sync] service endpoint resolved to 203.0.113.10 diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/rotation/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/rotation/Log_1.log new file mode 100644 index 000000000..11199c937 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/rotation/Log_1.log @@ -0,0 +1,2 @@ +2026-05-04T07:59:59.9990000Z INFO Event None 41 1a2b3c4d-0001-4000-8000-000000000002 12-0-0 [Sync] rollover boundary record +2026-05-04T08:00:00.5000000Z INFO Event None 42 1a2b3c4d-0001-4000-8000-000000000002 12-0-0 [Sync] current file first record diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/rotation/Log_2.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/rotation/Log_2.log new file mode 100644 index 000000000..ca70e9aa3 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/rotation/Log_2.log @@ -0,0 +1,2 @@ +2026-05-04T07:59:58.1000000Z INFO Event None 40 1a2b3c4d-0001-4000-8000-000000000002 12-0-0 [Sync] rotated file record +2026-05-04T07:59:59.9990000Z INFO Event None 41 1a2b3c4d-0001-4000-8000-000000000002 12-0-0 [Sync] rollover boundary record diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/same-timestamp-distinct-activity/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/same-timestamp-distinct-activity/Log_1.log new file mode 100644 index 000000000..3e44ae356 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/same-timestamp-distinct-activity/Log_1.log @@ -0,0 +1,2 @@ +2026-05-04T10:15:20.6660000Z INFO Event SignIn 7 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [SignIn] interactive sign-in started +2026-05-04T10:15:20.6660000Z INFO Event DeviceSync 8 1a2b3c4d-0001-4000-8000-000000000002 12-0-0 [Sync] background sync started diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/severity-levels/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/severity-levels/Log_1.log new file mode 100644 index 000000000..60999d42c --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/severity-levels/Log_1.log @@ -0,0 +1,6 @@ +2026-05-04T08:12:31.4410000Z INFO Event None 0 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [App Catalog] refreshing the assigned application list +2026-05-04T08:12:32.0020000Z WARNING Event None 1 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [App Catalog] catalog response was served from the local cache +2026-05-04T08:12:33.7710000Z ERROR Event None 2 1a2b3c4d-0001-4000-8000-000000000002 12-0-0 [App Install] install request rejected by the service +2026-05-04T08:12:34.0500000Z VERBOSE Trace DeviceSync 3 1a2b3c4d-0001-4000-8000-000000000003 12-0-0 [Sync] scheduling the next sync window +2026-05-04T08:12:35.1000000Z INFO Event None 4 1a2b3c4d-0001-4000-8000-000000000003 12-0-0 [Sync] the previous attempt failed and has been rescheduled +2026-05-04T08:12:36.2000000Z NOTICE Event None 5 1a2b3c4d-0001-4000-8000-000000000003 12-0-0 [Sync] an unexpected error was reported by the sync agent diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/truncated-boundaries/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/truncated-boundaries/Log_1.log new file mode 100644 index 000000000..375b7a829 --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v12-0-0/truncated-boundaries/Log_1.log @@ -0,0 +1,3 @@ +alog client because the previous file reached its size limit) +2026-05-04T13:30:00.4000000Z INFO Event None 0 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [Sync] first complete record +2026-05-04T13:30:01.5000000Z INFO Event None 1 1a2b3c4d-0001-40 \ No newline at end of file diff --git a/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v13-4-2/unknown-app-version/Log_1.log b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v13-4-2/unknown-app-version/Log_1.log new file mode 100644 index 000000000..a5fa989cb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/logs/v13-4-2/unknown-app-version/Log_1.log @@ -0,0 +1,2 @@ +2026-06-01T09:00:00.1000000Z INFO Event None 0 1a2b3c4d-0001-4000-8000-000000000001 13-4-2 [App Catalog] refreshing the assigned application list +2026-06-01T09:00:01.2000000Z INFO Event None 1 1a2b3c4d-0001-4000-8000-000000000001 13-4-2 [App Catalog] refresh complete diff --git a/references/log-intune-reference.md b/references/log-intune-reference.md index 895c77068..72c8fa9f2 100644 --- a/references/log-intune-reference.md +++ b/references/log-intune-reference.md @@ -207,9 +207,12 @@ WUfB ring policy values are visible in `MDMDiagHtmlReport.html` under Update CSP | Source | Path | Format | Purpose | |---|---|---|---| -| CP app logs | `C:\Users\\AppData\Local\Packages\Microsoft.CompanyPortal_8wekyb3d8bbwe\LocalState\Log_.log` | Plain text | Per-user CP events, errors, enrollment state | +| CP app logs | `C:\Users\\AppData\Local\Packages\Microsoft.CompanyPortal_8wekyb3d8bbwe\LocalState\Log_.log` | Column-aligned: ISO-8601 UTC, severity, category, scenario, sequence, activity GUID, app version, message | Per-user CP events, errors, enrollment state | +| CP bridge logs | `...\LocalState\Log._.log` (`BridgeLauncher`, `ConfigurationManagerBridge`, `IntuneManagementExtensionBridge`) | Same grammar as the app log | ConfigMgr `root\ccm\ClientSDK` queries and IME service calls made on behalf of CP | | MDM diagnostic export | `C:\Users\Public\Public Documents\MDMDiagnostics\` | .cab + .html | Exported via Settings > Accounts > Access work or school > Export management log files | +The record grammar is derived from a single published app-version sample (`12-0-0`), so the parser is version-scoped: a record reporting any other app version still parses but is reported as experimental/low confidence rather than presented as a validated read. + Collect diagnostics from the Company Portal app via **Help & support > Upload logs** or navigate directly to the LocalState folder. --- diff --git a/src-tauri/src/commands/bundle_ops.rs b/src-tauri/src/commands/bundle_ops.rs index ce2ededd2..d51463e6a 100644 --- a/src-tauri/src/commands/bundle_ops.rs +++ b/src-tauri/src/commands/bundle_ops.rs @@ -816,6 +816,7 @@ fn describe_parser_selection(parser_selection: &ParserSelectionInfo) -> String { ParserKind::DnsDebug => "Windows DNS Server debug log".to_string(), ParserKind::DnsAudit => "Windows DNS Server audit log".to_string(), ParserKind::CmtLog => "CMTrace Open structured log".to_string(), + ParserKind::CompanyPortal => "Company Portal app log".to_string(), }, } } diff --git a/src-tauri/tests/corpus/company_portal/clean/Log_1.log b/src-tauri/tests/corpus/company_portal/clean/Log_1.log new file mode 100644 index 000000000..e5a516eca --- /dev/null +++ b/src-tauri/tests/corpus/company_portal/clean/Log_1.log @@ -0,0 +1,3 @@ +2026-05-04T08:12:31.4410000Z INFO Event None 0 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [App Catalog] refreshing the assigned application list +2026-05-04T08:12:32.0020000Z WARNING Event None 1 1a2b3c4d-0001-4000-8000-000000000001 12-0-0 [App Catalog] catalog response was served from the local cache +2026-05-04T08:12:33.7710000Z ERROR Event AppInstall 2 1a2b3c4d-0001-4000-8000-000000000002 12-0-0 [App Install] install request rejected by the service diff --git a/src-tauri/tests/corpus/company_portal/negative/Log_1.log b/src-tauri/tests/corpus/company_portal/negative/Log_1.log new file mode 100644 index 000000000..52082db0f --- /dev/null +++ b/src-tauri/tests/corpus/company_portal/negative/Log_1.log @@ -0,0 +1,3 @@ +2026-05-04T08:12:31.4410000Z INFO Startup Foreground 0 Shell 1.2.3 session started +2026-05-04T08:12:32.0020000Z WARN Startup Foreground 1 Shell 1.2.3 tile refresh deferred +2026-05-04T08:12:33.7710000Z INFO Shutdown Background 2 Shell 1.2.3 session ended diff --git a/src-tauri/tests/parser_supported_formats.rs b/src-tauri/tests/parser_supported_formats.rs index fb6e3576e..fe2f03666 100644 --- a/src-tauri/tests/parser_supported_formats.rs +++ b/src-tauri/tests/parser_supported_formats.rs @@ -6,7 +6,7 @@ use app_lib::parser::ResolvedParser; use std::collections::BTreeSet; use std::path::PathBuf; -const DECLARED_PARSER_KINDS: [ParserKind; 20] = [ +const DECLARED_PARSER_KINDS: [ParserKind; 21] = [ ParserKind::Ccm, ParserKind::Simple, ParserKind::Timestamped, @@ -27,6 +27,7 @@ const DECLARED_PARSER_KINDS: [ParserKind; 20] = [ ParserKind::DnsDebug, ParserKind::DnsAudit, ParserKind::CmtLog, + ParserKind::CompanyPortal, ]; fn contract_name(kind: ParserKind) -> &'static str { @@ -51,6 +52,7 @@ fn contract_name(kind: ParserKind) -> &'static str { ParserKind::DnsDebug => "dns_debug", ParserKind::DnsAudit => "dns_audit", ParserKind::CmtLog => "cmtlog", + ParserKind::CompanyPortal => "company_portal", } } @@ -447,6 +449,40 @@ fn text_contract_cmtlog() { assert_eq!(result.entries[3].whatif, Some(true)); } +#[test] +fn text_contract_company_portal() { + let (result, selection) = parse_fixture("company_portal/clean/Log_1.log"); + assert_selection( + &selection, + ParserKind::CompanyPortal, + ParserImplementation::CompanyPortal, + RecordFraming::LogicalRecord, + ); + assert_eq!(result.format_detected, LogFormat::Timestamped); + assert_eq!(result.parse_errors, 0); + assert_eq!(result.entries.len(), 3); + assert_eq!(result.entries[0].component.as_deref(), Some("App Catalog")); + assert_eq!(result.entries[1].severity, Severity::Warning); + assert_eq!(result.entries[2].severity, Severity::Error); + assert_eq!( + result.entries[0].timestamp_display.as_deref(), + Some("2026-05-04 08:12:31.441") + ); + // Field 5 is a sequence, not a thread id; it must never populate a thread. + assert!(result.entries[0].thread.is_none()); +} + +#[test] +fn company_portal_detection_requires_record_structure_not_just_the_file_name() { + // The exact LocalState file name with an unrelated UWP package's records. + let selection = detect_fixture("company_portal/negative/Log_1.log"); + assert_eq!(selection.parser, ParserKind::Timestamped); + assert_eq!( + selection.implementation, + ParserImplementation::GenericTimestamped + ); +} + #[test] fn specialization_contract_ime() { let (result, selection) = parse_fixture("ime/multiline/HealthScripts.log"); diff --git a/src/lib/column-config.ts b/src/lib/column-config.ts index 7c118e07c..addfcb688 100644 --- a/src/lib/column-config.ts +++ b/src/lib/column-config.ts @@ -377,6 +377,7 @@ const PARSER_COLUMN_MAP: Record = { dnsDebug: ["severity", "dateTime", "dnsDirection", "dnsProtocol", "queryName", "queryType", "responseCode", "sourceIp", "dnsFlags", "message"], dnsAudit: ["severity", "dateTime", "dnsEventId", "queryName", "queryType", "responseCode", "zoneName", "sourceIp", "message"], cmtLog: ["severity", "dateTime", "message", "component"], + companyPortal: ["severity", "dateTime", "message", "component"], }; /** Default columns used before any file is loaded. */ diff --git a/src/stores/log-store.ts b/src/stores/log-store.ts index a5c9858d6..d1f40ae2e 100644 --- a/src/stores/log-store.ts +++ b/src/stores/log-store.ts @@ -282,6 +282,8 @@ function getParserLabel(parser: ParserSelectionInfo["parser"]): string { return "DNS Audit (EVTX)"; case "cmtLog": return "CmtLog"; + case "companyPortal": + return "Company Portal"; } } @@ -323,6 +325,8 @@ function getImplementationLabel( return "DNS audit EVTX parser"; case "cmtLog": return "CmtLog parser"; + case "companyPortal": + return "Company Portal Windows log parser"; } } diff --git a/src/types/log.ts b/src/types/log.ts index ea0d9865f..3a25b424e 100644 --- a/src/types/log.ts +++ b/src/types/log.ts @@ -23,7 +23,8 @@ export type ParserKind = | "secureBootLog" | "dnsDebug" | "dnsAudit" - | "cmtLog"; + | "cmtLog" + | "companyPortal"; export type ParserImplementation = | "ccm" | "simple" @@ -41,7 +42,8 @@ export type ParserImplementation = | "secureBootLog" | "dnsDebug" | "dnsAudit" - | "cmtLog"; + | "cmtLog" + | "companyPortal"; export type ParserProvenance = "dedicated" | "heuristic" | "fallback"; export type ParseQuality = "structured" | "semiStructured" | "textFallback"; export type RecordFraming = "physicalLine" | "logicalRecord"; From dac435294d08a5c65889d95b7a7bc35028698de8 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 31 Jul 2026 01:56:31 -0400 Subject: [PATCH 2/2] docs(intune): make the Company Portal contract claims match the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A code review of this branch produced six findings. None reached the confidence bar to post as blocking review comments, but four were verified accurate and are documentation/API-surface defects worth correcting rather than shipping. Before: several doc comments claimed more than the implementation delivers. * models.rs described field 5 as a "monotonic sequence value" while grammar.rs documented the same field as "semantics unproven". One published record cannot establish monotonicity, and nothing checks it. The claim is removed; the field is now described as an unsigned integer of unproven semantics, which is what the evidence supports. * CompanyPortalTimestampKind::Invalid was documented as "the field had the right shape but is not a real instant", but nothing ever constructed it: parse_utc_instant returns None for that input, so the record is framed Malformed and reaches the document with timestamp: None. The variant was dead public API on a published crate describing behavior that does not happen. Removed, and the enum now documents what actually occurs. Dropping a half-resolved timestamp is the correct behavior, so only the type and its doc change. * matches_company_portal_log_record claimed it was "used by parser::detect". parser::detect calls classify_line directly, because it needs the classification to count validated app versions rather than a bool. The function is the house-convention boolean wrapper; the doc now says so. * The module claimed losslessness in three places while framing.rs strips trailing whitespace from every line and drops blank lines entirely. Neither is reversible from raw_text. The claims are narrowed to what holds — a record the grammar cannot read is still reported rather than dropped — and framing.rs now names both exceptions explicitly, including which rule has to change if a multi-line payload containing a blank line is ever observed. Why this seam: these are contract statements on a crate published to crates.io, in a module whose entire premise is not claiming more than the evidence proves. A doc that overclaims is the same defect class the module exists to avoid. Verified on this commit: cargo test --locked -p cmtraceopen-parser -> lib 403 passed, company_portal_windows_logs 32 passed, esp_diagnostics 222 passed, 0 failed cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings -> clean cargo fmt --check --all -> no diff in any file this branch touches Refs #366 Co-Authored-By: Claude Opus 5 --- .../windows/company_portal/logs/detect.rs | 6 ++++- .../windows/company_portal/logs/document.rs | 2 +- .../windows/company_portal/logs/framing.rs | 12 ++++++++-- .../windows/company_portal/logs/models.rs | 24 +++++++++++++++---- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/detect.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/detect.rs index a88db0524..9f8bbe312 100644 --- a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/detect.rs +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/detect.rs @@ -40,7 +40,11 @@ pub fn classify_line(line: &str) -> Option { /// Check whether a line matches the Company Portal Windows log record grammar. /// -/// House-convention matcher used by `parser::detect` and by tests. +/// The boolean convenience form of [`classify_line`], kept to match the +/// `matches_*_record` house convention used by the other dedicated parsers. +/// `parser::detect` calls [`classify_line`] directly rather than this, because +/// it needs the returned classification to count validated app versions, not +/// just a yes/no. pub fn matches_company_portal_log_record(line: &str) -> bool { classify_line(line).is_some() } diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/document.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/document.rs index 06710b03a..6d9697f72 100644 --- a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/document.rs +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/document.rs @@ -153,7 +153,7 @@ fn build_coverage( }, detail: Some(format!( "{} read {parsed_count} of {total_records} record(s) with grammar V1; \ - {unreadable} record(s) did not match and are preserved verbatim.", + {unreadable} record(s) did not match and are preserved as source text.", file.file_name )), }); diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/framing.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/framing.rs index 448687bc0..a5bcffbb1 100644 --- a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/framing.rs +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/framing.rs @@ -5,8 +5,16 @@ //! //! Whether Company Portal ever writes a payload across several lines is not //! established by any published evidence. `V1` therefore uses the reading that -//! is lossless either way: a line that does not open a record belongs to the -//! record above it. +//! is lossless either way: a non-empty line that does not open a record belongs +//! to the record above it. +//! +//! Two exceptions, stated here because the rest of the module claims +//! losslessness: trailing whitespace is stripped from every line before the +//! record test (a record starts in column 0, so the test has to run on the +//! trimmed line), and a blank line is dropped rather than appended as a +//! continuation. Neither is reversible from `raw_text`. If a real multi-line +//! payload containing a blank line is ever observed, the blank-line skip is the +//! rule that has to change. //! //! A line that *does* look like a record start but fails validation is not a //! continuation — it closes the previous record and is reported as a malformed diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/models.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/models.rs index ffaff938e..5047a33f6 100644 --- a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/models.rs +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/logs/models.rs @@ -2,7 +2,10 @@ //! //! Serde recipe follows the ESP module: `rename_all = "camelCase"`, no //! `skip_serializing_if`, determinism from declaration order. Every type that -//! can fail to parse keeps the original text so nothing observed is lost. +//! can fail to parse keeps its source text, so a record the grammar cannot read +//! is still reported rather than dropped. See [`super::framing`] for the two +//! documented exceptions to byte-for-byte fidelity (trailing whitespace and +//! blank lines). use serde::{Deserialize, Serialize}; @@ -75,13 +78,19 @@ pub struct CompanyPortalSeverity { } /// How far a record timestamp could be resolved. +/// +/// Only resolved timestamps are represented. A field that has the right shape +/// but is not a real instant (`2026-13-45T99:99:99.0000000Z`) does not produce +/// a timestamp at all: `parse_utc_instant` rejects it, the record is framed as +/// [`FramedRecordKind::Malformed`], and it reaches the document with +/// `timestamp: None` plus a coverage row. There is deliberately no "invalid +/// instant" variant, because a half-resolved timestamp would be a claim the +/// evidence does not support. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum CompanyPortalTimestampKind { /// Resolved to an absolute UTC instant. Utc, - /// The field had the right shape but is not a real instant. - Invalid, } /// A record timestamp. `raw_text` is always the field exactly as written. @@ -196,7 +205,8 @@ pub struct CompanyPortalLogRecord { /// a null scenario; it is kept as the string it is, not turned into an /// absent field. pub scenario: Option, - /// Field 5 — monotonic sequence value. Not proven to be a thread id, so it + /// Field 5 — unsigned integer whose semantics are unproven. It is *not* + /// asserted to be monotonic, and it is not proven to be a thread id, so it /// is never mapped onto a thread column. pub sequence: Option, /// Field 6 — correlation/activity identifier. @@ -208,7 +218,11 @@ pub struct CompanyPortalLogRecord { /// Field 8 onward, verbatim, including any nested legacy ConfigMgr trace /// text and its own day-first date. pub message: String, - /// The record's original text, including continuation lines. + /// The record's source text, head plus any continuation lines, joined with + /// `\n`. Trailing whitespace is stripped from each line before framing (a + /// record starts in column 0, so that test has to run on the trimmed line), + /// and blank lines are not carried. Nothing else is altered: the nested + /// legacy ConfigMgr trace text and its day-first date survive verbatim. pub raw_text: String, }