Repository navigation
feat(intune): parse Company Portal Windows LocalState logs #460
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 56 commits
ad9eba9
204f1a8
51ba6f4
c748bb9
dedcd55
d67d616
d946b1c
062be6f
c7c0994
60630ac
b8e0ccd
2839eb9
5fd48dc
8c2de02
76655aa
64416dc
706f7e8
9c58ae9
8853278
2d2ace2
b0a931b
48a5f74
1a8c9a3
3eb9750
cf3df61
0fc9696
7551b60
c52084e
c0dd7e0
0512671
9c7e3fb
b666ad2
ca4ac2c
872a0ac
de85431
27dcbcb
43e75fb
57d7227
4b7274e
738131a
35cca20
2450d7c
3ee33ee
165f6d4
d0765e4
04e1ecb
5900f88
90cfe12
0138373
445bc99
474643a
98b38a3
176ba82
1553537
0838bec
8a65ef2
46e7a6a
e485cac
e4b98d8
aceb2d1
08b6b00
a4c6ab6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -242,8 +242,12 @@ 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 | ||
| # Run all parser-crate targets on Windows in addition to the Linux checks. | ||
| - 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 | ||
|
Comment on lines
+249
to
+250
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Does the parser crate contain any Windows-gated code that clippy would only lint on Windows?
rg -n 'cfg\(target_os *= *"windows"\)|cfg\(windows\)|target_family *= *"windows"' crates/cmtraceopen-parser || echo "no windows-gated code in cmtraceopen-parser"
rg -n -A20 '^\[(target|dependencies|features)' crates/cmtraceopen-parser/Cargo.tomlRepository: adamgell/cmtraceopen Length of output: 480 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- workflow context ---'
sed -n '60,100p;225,260p' .github/workflows/cmtrace-ci.yml
printf '%s\n' '--- parser crate source files and cfg attributes ---'
git ls-files crates/cmtraceopen-parser
rg -n 'cfg|target_os|target_family|windows|winapi|windows-sys' crates/cmtraceopen-parser || true
printf '%s\n' '--- workspace and package metadata ---'
sed -n '1,80p' crates/cmtraceopen-parser/Cargo.toml
rg -n -A12 -B3 'cmtraceopen-parser|workspace|target\.' Cargo.toml crates .github/workflows/cmtrace-ci.ymlRepository: adamgell/cmtraceopen Length of output: 50377 🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
workflow = Path(".github/workflows/cmtrace-ci.yml").read_text()
manifest = Path("crates/cmtraceopen-parser/Cargo.toml").read_text()
sources = [
p for p in Path("crates/cmtraceopen-parser").rglob("*")
if p.is_file() and p.suffix in {".rs", ".toml"}
]
windows_cfg = []
for path in sources:
text = path.read_text(errors="replace")
if re.search(r'#\s*\[\s*cfg\s*\([^]]*(?:windows|target_os|target_family)[^]]*\)', text):
windows_cfg.append(str(path))
target_sections = re.findall(
r'(?ms)^\[target[^\]]*\].*?(?=^\[|\Z)', manifest
)
commands = re.findall(
r'(?ms)^\s*- name: (Parser crate tests|Parser crate clippy|Test parser crate contracts|Clippy parser crate)\s*$.*?^\s+run: (.+)$',
workflow,
)
print("windows_cfg_files:", windows_cfg or "none")
print("target_manifest_sections:", len(target_sections))
print("parser_commands:")
for name, command in commands:
print(f" {name}: {command}")
print("linux_command_count:", workflow.count(
"cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings"
))
PYRepository: adamgell/cmtraceopen Length of output: 10627 Remove the redundant Windows parser clippy step. The parser crate has no Windows-gated source or target-specific manifest sections. Keep the Windows test step for Windows path handling. Rename the steps to 🤖 Prompt for AI Agents |
||
|
|
||
| - name: Test native ESP diagnostics | ||
| run: cargo test --locked -p cmtrace-open --all-features --test esp_diagnostics_sources | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,229 @@ | ||
| //! Detection confirmation for Company Portal Windows LocalState logs. | ||
| //! | ||
| //! # Why a file name is never enough | ||
| //! | ||
| //! `Log_<n>.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. | ||
| /// | ||
| /// Presence of this value already means the line was a confirmed record | ||
| /// (`classify_line` returns `None` otherwise), so the only variable fact is | ||
| /// whether the app version is in the validated set. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub struct CompanyPortalLineClassification { | ||
| /// 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<CompanyPortalLineClassification> { | ||
| let fields = parse_record_fields(line)?; | ||
| Some(CompanyPortalLineClassification { | ||
| app_version_is_validated: matches!( | ||
| fields.app_version.support, | ||
| CompanyPortalGrammarSupport::Validated | ||
| ), | ||
| }) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /// Check whether a line matches the Company Portal Windows log record grammar. | ||
| /// | ||
| /// 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() | ||
| } | ||
|
|
||
| /// 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_<n>.log` (the main app log) and `Log.<BridgeName>_<n>.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_<n>` — 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.<BridgeName>_<n>` — 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<u32> { | ||
| 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")); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: adamgell/cmtraceopen
Length of output: 10130
🏁 Script executed:
Repository: adamgell/cmtraceopen
Length of output: 24113
Add
-whitespacetosrc-tauri/tests/fixtures/**.These fixtures contain CRLF data, and
git diff --checkreports the deliberate CR bytes as trailing whitespace.🤖 Prompt for AI Agents