Skip to content

feat(intune): deterministic Company Portal Windows package-state evidence - #395

Merged
adamgell merged 6 commits into
mainfrom
claude/367-company-portal-package-state
Jul 31, 2026
Merged

feat(intune): deterministic Company Portal Windows package-state evidence#395
adamgell merged 6 commits into
mainfrom
claude/367-company-portal-package-state

Conversation

@adamgell

@adamgell adamgell commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Closes #367.

Before

crates/cmtraceopen-parser/src/collector/profile_data.json collected Company Portal and
Authenticator package information by running
Get-AppxPackage -AllUsers | ... | Format-List and writing human-formatted text to
appx-intune-packages.txt. Nothing in the repo parsed that file. Format-List output varies
by locale, PowerShell version, field ordering, wrapping, and missing values, so it is not a
stable basis for a public parser contract.

What changes

A new cmtraceopen_parser::intune::portal::windows::company_portal::package_state module, the
first occupant of the intune::portal tree:

  • Versioned JSON capture schema (COMPANY_PORTAL_PACKAGE_STATE_SCHEMA_VERSION = 1) as the
    canonical input, with capture metadata (command status, per-scope coverage, adapter/Windows/
    PowerShell versions, locale) and explicit package rows.
  • Deterministic findings layer covering installed fact, absence from captured scope,
    package status problem, version mismatch against a separately supplied expected fact,
    multiple registrations, incomplete or denied query, malformed capture, and unsupported schema.
  • Experimental legacy Format-List adapter that requires locale and adapter metadata and
    refuses rather than guessing on non-English, wrapped, or merged input. A refusal is a
    distinct outcome from an empty capture.
  • Opt-in redaction projection that clones rather than mutates, masking install locations,
    capture error messages, and any user identifier that reaches the model.
  • Collector updated to emit the documented JSON envelope via ConvertTo-Json instead of
    Format-List, wrapped in try/catch so a denied -AllUsers query reports
    commandStatus: "accessDenied" with denied scope coverage rather than a silent empty success.

The single most important rule from the issue is enforced and tested in both directions:
absence is claimable only when the command completed and the relevant scope's coverage is
complete.
A missing row under a failed, denied, capped, or not-queried scope produces
coverage, not a package-absence finding. Absence is also decided per scope rather than per app,
so a registration present only in currentUser is correctly reported as absent from a
completely enumerated allUsers.

Why this seam

The issue's core complaint is that a public contract should not depend on display-formatted
PowerShell. Splitting it as (native adapter emits versioned JSON) plus (pure crate validates,
reduces, and redacts) keeps the parser crate wasm-compatible and I/O-free while moving the
unstable rendering behind a schema the collector owns. Legacy text import stays reachable for
already-collected bundles but is marked source: legacyFormatList and never becomes canonical.

Two implementation details worth a reviewer's attention:

  • raw_preserving_string_enum! was promoted from esp/models.rs to a shared src/wire.rs
    (3 files touched) so there is exactly one copy rather than a duplicate in the portal tree.
  • evtx enables serde_json/preserve_order, and Cargo unifies features workspace-wide. That
    means serde_json::Map is an IndexMap under cargo test --locked but a BTreeMap under
    cargo test -p cmtraceopen-parser. Preserved raw blobs are canonicalized with sorted keys on
    ingest so golden bytes are identical under either build. Without this, the golden tests pass
    per-crate and fail workspace-wide.

Fixture matrix

All 14 scenarios required by the issue are present under
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/<scenario>/,
embedded with include_str! (the crate does no filesystem I/O). A test asserts the scenario
directory listing matches the expected 14 names, so a dropped scenario fails the build.

Review cycle

This PR was driven through /code-review, CodeRabbit, and GitHub Copilot. Ten findings were
accepted and fixed; each fix carries a test confirmed to fail against the previous
implementation. The substantive ones:

Finding Source Fix
Redaction projection not idempotent past nine identifiers (pseudonyms renumbered on a second pass, because [redacted-user-10] sorts before [redacted-user-1]) /code-review already-redacted values are terminal
Finding messages carried the adapter error message and scope-coverage detail, both classified sensitive, past a redaction projection that never sees PackageStateFinding CodeRabbit findings carry the stable error code only
Absence decided per app rather than per scope, so a currentUser-only registration never reported absence from a completely enumerated allUsers Copilot absence decided per scope
An omitted status field deserialized to an empty Unknown and was reported as an Error-severity package health problem CodeRabbit unreported status is silent
A missing blank separator merged two Format-List records into one row and reported success CodeRabbit a repeated Name label refuses with AmbiguousRecord
Access denial classified by matching English words, so a localized host reported accessDenied as failed CodeRabbit checks FullyQualifiedErrorId and the E_ACCESSDENIED HResult first
Windows adapter test decoded stdout with from_utf8_lossy, corrupting Windows-1252 bytes CodeRabbit detect_encoding + decode_bytes, per CLAUDE.md
LegacyRefusal.detail quoted the offending source line verbatim CodeRabbit reports position and length instead
Version-mismatch ids collided across facts sharing an expected version CodeRabbit the fact source is part of the id
appx-info omitted the parseHints every other JSON-emitting command declares /code-review hint added, asserted by test

Verified

Run locally on 2b9d3d5a, the pushed head, against a clean tree:

Command Result
cargo test --locked -p cmtraceopen-parser --test company_portal_windows_package_state 27 passed, 0 failed
cargo test --locked 1391 passed, 0 failed
cargo clippy --locked --all-targets -- -D warnings exit 0
cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown exit 0
npx tsc --noEmit exit 0

Baseline on origin/main for comparison: 1362 passed, 0 failed, with clippy, wasm, and tsc all
exit 0. The branch adds 29 tests.

The Windows-gated test

src-tauri/src/collector/mod.rs carries a test under
#[cfg(all(test, target_os = "windows"))] that runs the embedded adapter command and parses its
output. It cannot run on the macOS development host, and Windows cross-compilation is blocked
there, so locally it was only type-checked.

It has now run for real on CI and passed. From the ESP Diagnostics (Windows) job log on
2b9d3d5a, step "Test full Windows application workspace":

test collector::appx_adapter_windows_tests::appx_adapter_emits_a_parseable_package_state_capture ... ok

Worth recording for future changes to this area: Rust MSRV (1.88 / windows-latest) does not
exercise it. That job runs cargo +1.88 check --workspace --all-features with no
--all-targets, so it never compiles cfg(test) code at all. The job that runs this test is
ESP Diagnostics (Windows), whose final step is
cargo test --locked -p cmtrace-open --all-features - cmtrace-open being the crate the test
lives in.

cargo fmt --check --all was deliberately not run repo-wide. It is not a CI gate in this repo
and the tree has pre-existing drift.

Unrelated issues found while working this PR

Neither is caused by this change and neither is fixed here:

  • .github/workflows/label-new-items.yml (added by ci: auto-label new issues and PRs by title pattern #383) fails on every new PR with
    Resource not accessible by integration. It declares only issues: write; labeling a pull
    request also needs pull-requests: write.
  • src-tauri/tests/cmtlog_parser.rs::header_entry_parsed_correctly is flaky, failing roughly
    1 full-suite run in 5. TempLogFixture::new names its temp directory from
    SystemTime::now().as_nanos(), parallel tests in that binary collide on one directory, and
    Drop calls remove_dir_all out from under the other test.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added Windows Company Portal and Authenticator package-state collection with versioned, compact JSON output.
    • Added detection of installation issues, duplicate registrations, version mismatches, missing packages, and incomplete or failed collection scopes.
    • Added privacy-conscious exports that redact sensitive paths, errors, and user identifiers.
    • Added support for importing compatible legacy package reports, with clear handling for unsupported or ambiguous input.
  • Bug Fixes

    • Improved preservation of unknown data and compatibility with future capture schema versions.
  • Tests

    • Added broad coverage for parsing, findings, privacy redaction, diagnostics, and collection scenarios.

Copilot AI review requested due to automatic review settings July 31, 2026 14:25
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 268aec45-81fa-4aee-850e-69af05a57fc0

📥 Commits

Reviewing files that changed from the base of the PR and between e511b31 and 2b9d3d5.

📒 Files selected for processing (4)
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs
  • crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs
  • crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs

📝 Walkthrough

Walkthrough

The PR adds a versioned Windows Company Portal package-state contract. It replaces formatted collector output with JSON, adds parsing and deterministic findings, supports legacy imports, provides redacted exports, and adds comprehensive fixtures and tests.

Changes

Company Portal package-state evidence

Layer / File(s) Summary
Capture contract and parser
crates/cmtraceopen-parser/src/intune/..., crates/cmtraceopen-parser/src/wire.rs
Defines versioned capture models, raw-preserving enums, structured errors, future-schema handling, unknown-field retention, and public module exports.
Native JSON collection
crates/cmtraceopen-parser/src/collector/profile_data.json, crates/cmtraceopen-parser/src/collector/profile.rs, src-tauri/src/collector/mod.rs
The AppX collector emits versioned JSON with package metadata, scope coverage, command status, errors, and sensitive-field classifications. Tests validate the emitted capture.
Finding derivation
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs
Derives deterministic findings for incomplete queries, installed packages, status problems, duplicate registrations, version mismatches, malformed captures, and absent packages.
Legacy Format-List import
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs
Imports supported English Format-List records as partial evidence and returns explicit refusal outcomes for unsupported or ambiguous input.
Redacted export
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs
Creates an idempotent redacted projection that masks sensitive values and applies stable pseudonyms to user identifiers.
Contract tests and fixtures
crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs, crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/*
Adds tests and fixtures for parsing, findings, serialization, future schemas, legacy imports, redaction, collector failures, and the fourteen required scenarios.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AppXAdapter
  participant PackageStateParser
  participant FindingDeriver
  participant RedactedExport
  AppXAdapter->>PackageStateParser: emit and parse versioned JSON capture
  PackageStateParser->>FindingDeriver: provide PackageStateCapture
  FindingDeriver-->>PackageStateParser: return deterministic findings
  PackageStateParser->>RedactedExport: provide parsed capture
  RedactedExport-->>PackageStateParser: return redacted projection
Loading

Possibly related issues

  • Issue 371 — Establishes Company Portal as a first-class intune::portal parser surface with privacy-preserving, fixture-driven evidence handling; this PR adds the Windows package-state counterpart.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: deterministic Windows Company Portal package-state evidence for Intune.
Linked Issues check ✅ Passed The implementation meets the linked issue requirements for deterministic JSON evidence, scoped findings, legacy refusal, privacy, fixtures, and Windows validation [#367].
Out of Scope Changes check ✅ Passed The changes support the linked objectives, including shared parsing infrastructure, collector output, schema discovery, tests, and package-state evidence.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@adamgell adamgell added feature New feature intune Microsoft Intune related parser Log parser related portal Company Portal related enhancement New feature or request labels Jul 31, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a deterministic, versioned JSON capture contract and corresponding findings/redaction logic for Windows Company Portal (and Authenticator) AppX package-state evidence under cmtraceopen_parser::intune::portal, replacing reliance on locale-/format-dependent PowerShell Format-List output.

Changes:

  • Adds intune::portal::windows::company_portal::package_state schema + findings + redaction, including a strict legacy Format-List import path that can refuse unsafe input.
  • Updates the collector’s embedded Windows command profile to emit the JSON envelope via ConvertTo-Json, plus adds a Windows-only native test to validate the adapter output parses.
  • Adds a focused contract test suite and a 14-scenario fixture matrix (including determinism and privacy redaction).

Reviewed changes

Copilot reviewed 33 out of 33 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src-tauri/src/collector/mod.rs Adds Windows-only test that runs the embedded AppX adapter and validates schema parseability.
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/unknown-future-schema/capture.json Fixture for forward-schema handling (unsupported schema + raw preservation).
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/privacy-paths/golden-redacted.json Golden output for redacted export projection.
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/privacy-paths/capture.json Fixture containing identity/path data to validate redaction.
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/per-user-only-registration/capture.json Fixture for current-user-only registration + all-users not queried coverage.
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/package-status-problem/capture.json Fixture for non-OK package status producing an error finding.
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/multiple-registrations/capture.json Fixture for duplicate registrations of the same package family.
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/malformed-json/capture.json Fixture validating malformed JSON becomes a typed error + finding (not panic).
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-refused/wrapped.txt Legacy sample that must refuse due to wrapped/truncated records.
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-refused/non-english.txt Legacy sample that must refuse due to non-English localization.
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-english/packages.txt Legacy English Format-List sample that imports as low-confidence evidence.
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/installed-company-portal/capture.json Fixture for installed Company Portal row and installed finding.
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/installed-company-portal-and-authenticator/capture.json Fixture for separate Company Portal + Authenticator rows.
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/deterministic-serialization/golden.json Golden bytes for deterministic serialization contract.
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/deterministic-serialization/capture.json Input permutation fixture for field-order-independent serialization.
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/command-failure/capture.json Fixture for failed command status producing coverage findings (no absence claims).
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/access-denied-incomplete-query/capture.json Fixture for access-denied capture and denied scope coverage.
crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/absent-after-complete-all-users-capture/capture.json Fixture ensuring absence is only claimable with completed command + complete scope coverage.
crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs Contract tests covering the schema, findings, determinism, legacy import, and redaction.
crates/cmtraceopen-parser/src/wire.rs Adds shared raw_preserving_string_enum! macro for tolerant wire enums.
crates/cmtraceopen-parser/src/lib.rs Registers the new internal wire module.
crates/cmtraceopen-parser/src/intune/portal/windows/mod.rs Introduces Windows portal module root.
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs Implements opt-in redacted export projection (clone, mask identity/path data).
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs Defines versioned capture schema + canonicalization helper for deterministic JSON blobs.
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs Package-state module entrypoint: parse, preserve unknown fields, and derive findings.
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs Experimental legacy Format-List importer with refusal semantics and locale gating.
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs Deterministic findings layer with coverage gating and schema/absence rules.
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/mod.rs Exposes the Windows Company Portal module tree.
crates/cmtraceopen-parser/src/intune/portal/mod.rs Adds intune::portal root module documentation and exports.
crates/cmtraceopen-parser/src/intune/mod.rs Exposes the new portal module from the Intune surface.
crates/cmtraceopen-parser/src/esp/models.rs Switches ESP schema enums to use shared raw_preserving_string_enum!.
crates/cmtraceopen-parser/src/collector/profile.rs Adds a contract test asserting the embedded AppX adapter emits versioned JSON (not Format-List).
crates/cmtraceopen-parser/src/collector/profile_data.json Updates embedded collector command from Format-List text to versioned JSON capture output.

adamgell and others added 2 commits July 31, 2026 11:35
…odule

Before: `raw_preserving_string_enum!` lived as a private `macro_rules!` at the
top of `esp/models.rs`. It is the crate's one mechanism for round-tripping
unrecognized wire values losslessly, but any other schema module that wanted it
had to either make `esp` internals public or hand-copy the macro body.

This moves the macro to a new crate-internal `src/wire.rs` and has
`esp/models.rs` import it via `pub(crate) use`. The expansion now names serde
through absolute paths (`::serde::Serialize`, `::core::result::Result`), so a
caller no longer has to arrange for the right traits to be in scope.

This is the right seam because tolerance to unknown wire values is a crate-wide
property of public capture schemas, not an ESP detail. Keeping exactly one copy
means a future fix to the round-trip behavior lands everywhere at once.

Behavior is unchanged: the generated code is identical, and the `esp` public API
is untouched. Verified with `cargo check --locked --all-targets` (exit 0) and
`cargo clippy --locked --all-targets -- -D warnings` (exit 0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ence

Before: the collector ran `Get-AppxPackage -AllUsers | ... | Format-List` and
wrote the result to `appx-intune-packages.txt`. Nothing parsed that file, and
nothing could have parsed it safely: `Format-List` is a display rendering whose
labels are localized and whose field order, wrapping, and truncation vary by
console width and PowerShell version. There was no notion of query coverage at
all, so an unelevated run that enumerated nothing was indistinguishable from a
device with no Company Portal installed.

This adds `intune::portal::windows::company_portal::package_state`, a versioned
JSON capture schema plus a deterministic findings layer over it, and switches
the collector to emit that schema.

The capture (`schemaVersion 1`) carries provenance (capture time, adapter
version, Windows/PowerShell version, locale), an explicit `commandStatus`, and
per-scope `scopeCoverage`. Package rows carry name/family/full name, version,
architecture, publisher, signature kind, status, install state, and scope. Every
string enum round-trips unrecognized values through `Unknown(String)`, unknown
adapter fields are folded into a per-row `raw` bag, and a `schemaVersion` newer
than this build is not an error: the raw document is preserved, no package facts
are claimed, and an `UnsupportedSchema` finding is emitted.

The load-bearing rule is that absence is a claim about the capture, not the
device. `PackageAbsentFromCapturedScope` is emitted only when `commandStatus` is
`completed` AND the scope's coverage is `complete`. A denied, failed, capped,
timed-out, or never-queried scope produces `IncompleteQuery` instead. Both
directions are tested. `VersionMismatch` compares against an
`ExpectedPackageFact` the caller supplies; the crate never looks a version up.

`legacy.rs` can import old `Format-List` text, but refuses rather than guessing:
a missing or non-English locale, or any line that is not an unambiguous
label/value pair (which is what wrapping and truncation look like), returns
`LegacyImportOutcome::Refused` with a reason and a line number. Refusal is a
distinct variant from an imported-but-empty capture, imports are stamped
`source: legacyFormatList`, their coverage is `partial` so absence stays
structurally unclaimable, and every derived finding is capped to low confidence.

`redaction.rs` is an opt-in projection, never a mutation: install locations,
capture error messages, user identifiers (pseudonymized as `[redacted-user-N]`
from a `BTreeSet` so indices are stable), and user-profile paths inside
preserved raw blobs are masked. Publisher CN values and versions are left alone
so the export stays diagnostically useful.

This is the right seam because the schema, not the PowerShell rendering, is the
public contract. The pure crate does no I/O, no lookups, and no remediation; the
Windows adapter's only job is to fill the envelope truthfully, including
reporting its own failure.

Two things the design notes got wrong about this codebase, both found by tests:

1. `serde_json` IS built with `preserve_order` in the workspace build (`evtx`
   enables it and Cargo unifies features), so `serde_json::Map` is an `IndexMap`
   there and a `BTreeMap` under `cargo test -p cmtraceopen-parser`. The golden
   serialization tests passed per-crate and failed workspace-wide. Preserved
   blobs are now canonicalized with sorted keys on the way in, so the bytes are
   identical under either build.
2. `LegacyImportOutcome::Imported` has to box its payload;
   `clippy::large_enum_variant` rejects the unboxed form under `-D warnings`.

Verified on macOS (commands run from the repo root):
  npx tsc --noEmit                                                  exit 0
  cargo check --locked --all-targets                                exit 0
  cargo clippy --locked --all-targets -- -D warnings                exit 0
  cargo check --locked -p cmtraceopen-parser \
      --target wasm32-unknown-unknown                               exit 0
  cargo test --locked -p cmtraceopen-parser \
      --test company_portal_windows_package_state    19 passed, 0 failed
  cargo test --locked                             1382 passed, 0 failed
    (baseline on this branch point: 1362 passed, 0 failed; +19 integration
     tests and +1 collector profile test)

The collector command itself was executed end to end via `pwsh` on macOS, where
`Get-AppxPackage` does not exist: it produced a valid envelope with
`commandStatus: "failed"`, scope coverage `failed`, and the error detail, which
is exactly the shape the contract requires of a failed capture.

Not verifiable on this host: the `#[cfg(all(test, target_os = "windows"))]` test
in `src-tauri/src/collector/mod.rs`, which runs the embedded adapter command and
parses its output. It was compile-checked and clippy-checked by temporarily
retargeting the cfg to macOS, but it has never been executed; the Windows CI job
is its first real run.

Closes #367

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adamgell
adamgell force-pushed the claude/367-company-portal-package-state branch from c1bd7d9 to 1a3eac5 Compare July 31, 2026 15:37
@adamgell

Copy link
Copy Markdown
Owner Author

Code review

Found 1 issue:

  1. redacted_package_state_export is not idempotent once a capture carries 10 or more distinct user identifiers, contradicting its own doc comment ("The projection is idempotent: applying it to its own output is a no-op") and the adjacent claim that the pseudonyms are "order-independent". Pseudonym numbers are assigned by lexicographic position in a BTreeSet<String> of the raw identifier values. Feeding the output back in re-pseudonymizes the pseudonyms themselves, and "[redacted-user-10]" sorts before "[redacted-user-1]" because '0' < ']', so a second pass renumbers which row gets which label. Verified by running the projection twice over captures with 2, 10, and 15 identifiers: 2 is stable, 10 and 15 both remap (row 1 moves from [redacted-user-1] to [redacted-user-2]). The existing test package_state_redacted_export_masks_paths_and_user_identifiers_without_mutating_input only exercises 2 identifiers, where lexicographic and numeric order happen to coincide, so it does not catch this. The non-mutation half of the contract is fine.

// Stable, order-independent pseudonyms so the same identifier reads the
// same way everywhere in one export.
let identifiers: BTreeSet<String> = safe
.packages
.iter()
.filter_map(|row| row.user_identifier.as_ref())
.map(|identifier| identifier.value.clone())
.collect();
let pseudonyms: BTreeMap<String, String> = identifiers
.into_iter()
.enumerate()
.map(|(index, value)| (value, format!("[redacted-user-{}]", index + 1)))
.collect();

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

The redacted export numbered user pseudonyms by lexicographic position in a
BTreeSet of the raw identifier values, and a second pass re-pseudonymized the
pseudonyms it had just produced. Because "[redacted-user-10]" sorts before
"[redacted-user-1]" ('0' < ']'), any capture holding ten or more distinct
identifiers came back renumbered, so applying the projection to its own output
was not a no-op. That contradicted the function's own doc comment and broke the
stable-pseudonym guarantee across repeated exports of one capture. The existing
test used two identifiers, where lexicographic and numeric order coincide, so it
could not catch this.

An already-redacted value is now terminal: is_redaction_marker recognizes both
the plain mask and a numbered pseudonym, those values are excluded from the
numbering, and every write site leaves them untouched. Idempotence therefore no
longer depends on how many identifiers a capture holds.

Also carries the parseHints convention forward onto appx-info. Every other
JSON-emitting command in the collection profile declares a "json" hint, and the
evidence-bundle dialog searches on it, so converting this artifact from
Format-List text to JSON without the hint left it undiscoverable by that search.

Verified: the new test fails against the previous implementation and passes
against this one. cargo test --locked 1383 passed / 0 failed (1382 before, plus
the regression test); cargo clippy --locked --all-targets -- -D warnings exit 0;
cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown exit
0; npx tsc --noEmit exit 0.

Refs #367

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adamgell

Copy link
Copy Markdown
Owner Author

Fixed in f5a5feb.

An already-redacted value is now terminal: is_redaction_marker recognizes both the plain mask and a numbered pseudonym, those values are excluded from the numbering, and every write site leaves them untouched. Idempotence no longer depends on how many identifiers a capture holds.

Added package_state_redacted_export_stays_idempotent_beyond_nine_identifiers, which builds a 12-identifier capture and applies the projection twice. Confirmed it fails against the previous implementation and passes against this one, so it is a real regression guard rather than a test written to agree with the code.

One correction to my review comment above: I wrote that the bug also contradicted the adjacent "order-independent" claim. That was wrong. The pseudonyms genuinely are independent of the order rows appear in, which is what that comment meant. Only the idempotence claim was broken.

Also carried the parseHints convention forward onto appx-info in the same commit. Every other JSON-emitting command in the collection profile declares a "json" hint and EvidenceBundleDialog searches on it, so converting this artifact from Format-List text to JSON without the hint left it undiscoverable by that search. This scored below my reporting threshold so it is not in the review above, but it is a gap in this PR's own migration, so it is fixed here with a test asserting the hint is present.

Gates on f5a5feb: cargo test --locked 1383 passed / 0 failed (1382 before, plus the new test); cargo clippy --locked --all-targets -- -D warnings exit 0; cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown exit 0; npx tsc --noEmit exit 0.

push_absence_findings skipped an app entirely as soon as the capture held any
row for it, then emitted a per-scope message. So an app registered only in
currentUser, with both currentUser and allUsers enumerated completely, produced
no finding at all, even though it really is absent from allUsers. A per-user-only
Company Portal registration is a genuine deployment signal and the contract was
staying silent about it.

Absence is now decided per scope: for each completely enumerated scope, the app
is absent when no row for it lists that scope. A row carrying no scope
attribution suppresses the claim for that app entirely, because an unattributable
row might be the very registration in question, and over-claiming absence is the
failure mode this contract exists to prevent. The zero-row case is unchanged.

The doc comment also claimed absence was limited to apps the caller asked about,
while Company Portal has always been checked unconditionally. Reworded to match
what the code does.

Reported by GitHub Copilot on PR #395.

Verified: package_state_absence_is_decided_per_scope_not_per_app fails against
the previous implementation and passes against this one. cargo test --locked
1385 passed / 0 failed in the parser crate and workspace; cargo clippy --locked
--all-targets -- -D warnings exit 0; cargo check --locked -p cmtraceopen-parser
--target wasm32-unknown-unknown exit 0; npx tsc --noEmit exit 0.

Note: src-tauri/tests/cmtlog_parser.rs::header_entry_parsed_correctly flakes
independently of this change. TempLogFixture names its temp dir from
SystemTime::now().as_nanos(), parallel tests collide on one directory, and Drop
removes it out from under the other test. Pre-existing and tracked separately.

Refs #367

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (5)
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs (1)

407-408: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Correct the doc comment: Company Portal is checked unconditionally.

The doc comment states that absence is claimed "only for apps the caller asked about". Line 424 always seeds the list with PortalApp::CompanyPortal, whether or not the caller supplied a fact. The inline comment on line 422 states the real rule. Align the doc comment with the code.

♻️ Proposed fix
-/// Absence is claimed only for apps the caller asked about, and only against a
-/// scope the adapter proved it enumerated completely.
+/// Absence is claimed for Company Portal, which is the subject of this
+/// contract, plus any app the caller supplied a fact for. Every claim is made
+/// only against a scope the adapter proved it enumerated completely.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs`
around lines 407 - 408, Update the documentation comment above the relevant
findings logic to state that absence is claimed for caller-requested apps and
always for Company Portal, limited to scopes the adapter proved it enumerated
completely. Keep the behavior and inline comment unchanged.
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs (1)

360-369: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Remove the expect from camel_case_enum.

The expect message is correct only for enums that raw_preserving_string_enum generates. The function signature accepts any T: Deserialize, so a later call with a strict enum panics inside a pure parser on untrusted text. Require Default and fall back instead of panicking.

♻️ Proposed fix
-fn camel_case_enum<T: for<'de> Deserialize<'de>>(value: &str) -> T {
+fn camel_case_enum<T: for<'de> Deserialize<'de> + Default>(value: &str) -> T {
     let mut chars = value.chars();
     let camel = match chars.next() {
         Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
         None => String::new(),
     };
-    serde_json::from_value(Value::String(camel))
-        .expect("raw-preserving enums accept any string value")
+    // Raw-preserving enums accept any string, so this fallback is unreachable
+    // for them. It keeps a strict enum from panicking in a pure parser.
+    serde_json::from_value(Value::String(camel)).unwrap_or_default()
 }

PackageArchitecture, PackageSignatureKind, and PackageStatus need a Default impl for this change. Alternatively keep the current signature and drop the panic by returning Option<T>.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs`
around lines 360 - 369, Update camel_case_enum to require T: Default and replace
the expect-based deserialization with a non-panicking fallback to T::default()
when conversion fails. Add Default implementations for PackageArchitecture,
PackageSignatureKind, and PackageStatus, preserving the existing camel-case
transformation and successful deserialization behavior.
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs (1)

296-314: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test that keeps KNOWN_PACKAGE_ROW_FIELDS in sync with PackageRow.

The list duplicates the PackageRow field names by hand. If someone adds a field to PackageRow and forgets this list, the new field parses into its typed field and is also copied into raw, so the same value appears twice in the capture. A cheap guard is a test that serializes PackageRow::default() and compares the resulting object keys with this list.

♻️ Suggested guard test
#[test]
fn known_package_row_fields_match_serialized_keys() {
    let value = serde_json::to_value(PackageRow::default()).expect("row serializes");
    let mut keys: Vec<&str> = value
        .as_object()
        .expect("row is an object")
        .keys()
        .map(String::as_str)
        .collect();
    keys.sort_unstable();
    let mut known = KNOWN_PACKAGE_ROW_FIELDS.to_vec();
    known.sort_unstable();
    assert_eq!(keys, known);
}

Note: this test only works when no field is skipped during serialization.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs`
around lines 296 - 314, Add a unit test near PackageRow and
KNOWN_PACKAGE_ROW_FIELDS that serializes PackageRow::default(), extracts and
sorts its object keys, sorts a copy of KNOWN_PACKAGE_ROW_FIELDS, and asserts the
lists match. Ensure the test uses the existing serde serialization behavior and
remains valid only when all PackageRow fields are serialized.
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs (1)

53-79: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Sensitivity classification is recorded but never consulted.

mask() (Line 75-79) blanks any populated PackageStateClassifiedString regardless of its sensitivity value, and the user_identifier substitution (Line 55-62) redacts any non-marker value regardless of its sensitivity too. Right now this is safe, because the only values wired up are sensitive (installLocation) or restricted (userIdentifier). But the field exists specifically to classify values, and this logic ignores it.

If a future producer attaches a lower-sensitivity (for example, public) classified string to one of these fields to preserve a non-identity value, this code still discards it, contradicting the module's own stated goal to avoid over-redaction of diagnostic value. Gate the masking decision on sensitivity instead of on presence alone.

♻️ Proposed direction for sensitivity-aware masking
-fn mask(value: &mut Option<PackageStateClassifiedString>) {
-    if let Some(classified) = value.as_mut() {
-        classified.value = REDACTED.to_string();
-    }
-}
+fn mask(value: &mut Option<PackageStateClassifiedString>) {
+    if let Some(classified) = value.as_mut() {
+        if classified.sensitivity.requires_redaction() {
+            classified.value = REDACTED.to_string();
+        }
+    }
+}

(Requires models.rs to expose an equivalent predicate on the sensitivity type.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs`
around lines 53 - 79, Update mask and the user_identifier redaction in the
package redaction flow to consult each classified string’s sensitivity instead
of redacting solely based on field presence or marker status. Add or reuse a
predicate exposed by the sensitivity type in models.rs, and preserve values
whose sensitivity does not require masking while continuing to redact sensitive
or restricted values and existing redaction markers.
crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs (1)

514-517: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Align the assertion message with the implemented ordering.

sort_findings orders by PackageStateFindingKind::rank() and finding ID, not by severity. Change the message to "findings must be emitted in stable kind order". Do not sort severities ascending; PackageStateFindingSeverity orders Info < Warning < Error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs`
around lines 514 - 517, Update the assertion message in the ranks ordering check
to “findings must be emitted in stable kind order,” matching sort_findings’
rank-and-ID ordering. Keep the existing collection and sorting logic unchanged;
do not introduce severity-based sorting.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/cmtraceopen-parser/src/collector/profile_data.json`:
- Around line 1302-1307: Update the access-denial classification in the
PowerShell catch block for the AppX capture command: retain the
UnauthorizedAccessException check, then also inspect FullyQualifiedErrorId or
Exception.HResult for E_ACCESSDENIED/0x80070005 before falling back to message
matching. Ensure localized access-denied failures set commandStatus to
accessDenied and coverage to denied rather than failed.

In `@crates/cmtraceopen-parser/src/collector/profile.rs`:
- Around line 173-186: Update the required command fragments in the test around
the appx-info adapter command to assert the configured JSON depth as the
combined flag and value “-Depth 6”, replacing the current check that only
verifies “-Depth”. Keep the existing assertions for the other command options
unchanged.

In
`@crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs`:
- Around line 231-289: The derived finding text bypasses the redaction
projection and can expose sensitive values. In
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs:231-289,
update the finding construction to avoid interpolating
PackageCaptureError.message and PackageScopeCoverage.detail, or derive findings
from the redacted capture before export. In
crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs:137-146,
replace the raw source-line content in LegacyRefusal.detail with only the line
number and character count.
- Around line 386-390: Update the version-mismatch finding ID construction in
the findings push logic to include fact.source alongside the row index and
expected version. Preserve the existing ID prefix and ensure different sources
produce distinct IDs.
- Around line 312-327: Update the status check in the package-state findings
logic so PackageStatus::Unknown with an empty value is treated as unreported and
does not create a PackageStatusProblem finding. Continue reporting non-Ok
statuses when they contain an actual status value, while preserving the existing
finding construction for those cases.

In
`@crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs`:
- Around line 261-276: Update LegacyRecord::to_row to detect when the
identifying “Name” label appears more than once, case-insensitively, before
constructing the PackageRow. Return LegacyRefusalReason::AmbiguousRecord with
the record’s line context instead of emitting a row; keep the existing
missing/blank Name handling unchanged.

In `@src-tauri/src/collector/mod.rs`:
- Around line 43-48: Update the adapter execution assertion in the collector
test to parse and validate stdout even when the process exits non-zero, rather
than requiring output.status.success(). Preserve rejection of invalid execution
output, and include output.status.code() as exit_code in the parse-failure
message alongside the existing stderr details.
- Around line 50-52: Update the adapter stdout decoding before
parse_package_state_capture: decode bytes as UTF-8 with an encoding_rs
Windows-1252 fallback instead of String::from_utf8_lossy, preserving publisher
strings and paths without replacement-character corruption.

---

Nitpick comments:
In
`@crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs`:
- Around line 407-408: Update the documentation comment above the relevant
findings logic to state that absence is claimed for caller-requested apps and
always for Company Portal, limited to scopes the adapter proved it enumerated
completely. Keep the behavior and inline comment unchanged.

In
`@crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs`:
- Around line 360-369: Update camel_case_enum to require T: Default and replace
the expect-based deserialization with a non-panicking fallback to T::default()
when conversion fails. Add Default implementations for PackageArchitecture,
PackageSignatureKind, and PackageStatus, preserving the existing camel-case
transformation and successful deserialization behavior.

In
`@crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs`:
- Around line 296-314: Add a unit test near PackageRow and
KNOWN_PACKAGE_ROW_FIELDS that serializes PackageRow::default(), extracts and
sorts its object keys, sorts a copy of KNOWN_PACKAGE_ROW_FIELDS, and asserts the
lists match. Ensure the test uses the existing serde serialization behavior and
remains valid only when all PackageRow fields are serialized.

In
`@crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs`:
- Around line 53-79: Update mask and the user_identifier redaction in the
package redaction flow to consult each classified string’s sensitivity instead
of redacting solely based on field presence or marker status. Add or reuse a
predicate exposed by the sensitivity type in models.rs, and preserve values
whose sensitivity does not require masking while continuing to redact sensitive
or restricted values and existing redaction markers.

In `@crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs`:
- Around line 514-517: Update the assertion message in the ranks ordering check
to “findings must be emitted in stable kind order,” matching sort_findings’
rank-and-ID ordering. Keep the existing collection and sorting logic unchanged;
do not introduce severity-based sorting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4bcfc608-3d89-45b4-bbed-9e8dc4929e1b

📥 Commits

Reviewing files that changed from the base of the PR and between 03e55c3 and f5a5feb.

📒 Files selected for processing (33)
  • crates/cmtraceopen-parser/src/collector/profile.rs
  • crates/cmtraceopen-parser/src/collector/profile_data.json
  • crates/cmtraceopen-parser/src/esp/models.rs
  • crates/cmtraceopen-parser/src/intune/mod.rs
  • crates/cmtraceopen-parser/src/intune/portal/mod.rs
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/mod.rs
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/models.rs
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/redaction.rs
  • crates/cmtraceopen-parser/src/intune/portal/windows/mod.rs
  • crates/cmtraceopen-parser/src/lib.rs
  • crates/cmtraceopen-parser/src/wire.rs
  • crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/absent-after-complete-all-users-capture/capture.json
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/access-denied-incomplete-query/capture.json
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/command-failure/capture.json
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/deterministic-serialization/capture.json
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/deterministic-serialization/golden.json
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/installed-company-portal-and-authenticator/capture.json
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/installed-company-portal/capture.json
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-english/packages.txt
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-refused/non-english.txt
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/legacy-format-list-refused/wrapped.txt
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/malformed-json/capture.json
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/multiple-registrations/capture.json
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/package-status-problem/capture.json
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/per-user-only-registration/capture.json
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/privacy-paths/capture.json
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/privacy-paths/golden-redacted.json
  • crates/cmtraceopen-parser/tests/fixtures/intune/portal/windows/package_state/unknown-future-schema/capture.json
  • src-tauri/src/collector/mod.rs

Comment thread crates/cmtraceopen-parser/src/collector/profile_data.json
Comment thread crates/cmtraceopen-parser/src/collector/profile.rs
Comment thread src-tauri/src/collector/mod.rs Outdated
Comment thread src-tauri/src/collector/mod.rs Outdated
Six issues from the CodeRabbit review of PR #395. Each was verified against the
code before being accepted, and each fix carries a test that fails against the
previous implementation.

Privacy. Finding messages interpolated the adapter's error message and
scope-coverage detail, both classified sensitive in the capture schema.
PackageStateFinding is a separate type that redacted_package_state_export never
sees, so a redacted export still carried whatever those fields held: the
access-denied fixture's message contains a real-looking profile path. Findings
now carry the stable error code only, and the free text stays in the capture
where redaction covers it. LegacyRefusal.detail quoted the offending source line
for the same reason and now reports its position and length instead.

Correctness. PackageRow uses #[serde(default)], so a row omitting `status`
deserialized to an empty Unknown and was reported as an Error-severity package
health problem: a missing field became an invented fault. An unreported status
is now silent. Version-mismatch finding ids omitted the fact source, so two
facts naming the same app and expected version from different sources collided
on one id with two different messages; the source is now part of the id.

Legacy import. A missing blank separator merged two Format-List records into
one, and the import reported success carrying the first record's Name with both
records' fields mixed. This module refuses wrapped and truncated input precisely
so a bad read never looks like a good one, so a repeated identifying label now
refuses with AmbiguousRecord.

Collector. Access denial was classified by matching English words in the error
message, so a localized Windows host reported accessDenied as failed. The catch
block now checks FullyQualifiedErrorId and the E_ACCESSDENIED HResult before
falling back to message text. The profile test pinned only the -Depth flag and
now pins -Depth 6, since a lower value silently truncates scopeCoverage and
installLocation.

Windows adapter test. It asserted a successful exit code before parsing, which
contradicted its own acceptance of Failed and AccessDenied: those can only be
observed when the script exits 0 and reports the outcome inside the JSON. It now
parses first and treats a non-zero exit with a parseable capture as valid.
Stdout was decoded with from_utf8_lossy, which turns Windows-1252 bytes in a
publisher string or install path into U+FFFD; it now uses detect_encoding plus
decode_bytes, the UTF-8 with Windows-1252 fallback CLAUDE.md requires.

Verified: all five new parser tests fail against the previous implementation and
pass against this one. cargo test --locked 1390 passed / 0 failed (1385 before);
cargo clippy --locked --all-targets -- -D warnings exit 0; cargo check --locked
-p cmtraceopen-parser --target wasm32-unknown-unknown exit 0; npx tsc --noEmit
exit 0.

The edited Windows-gated test still cannot run on this macOS host. It was
type-checked by temporarily retargeting its cfg to macos and running cargo check
and clippy with --all-targets, then restoring the Windows gate. Windows CI
remains its first real execution.

Refs #367

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs (1)

897-914: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that the fixtures actually supply sensitive text.

Both privacy checks are conditional. If capture.capture.error becomes None, or every coverage.detail becomes None, the loop body asserts nothing and the test still passes. The literal "jrivera" and C:\Users check has the same weakness, because it also passes when the fixture no longer contains those values.

Add a precondition so the test fails when the fixture stops carrying sensitive text.

♻️ Proposed precondition assertion
     for source in [ACCESS_DENIED, COMMAND_FAILURE] {
         let capture = parse(source);
         let findings = derive_package_state_findings(&capture, &[]);
+        let sensitive: Vec<&str> = capture
+            .capture
+            .error
+            .as_ref()
+            .map(|error| error.message.as_str())
+            .into_iter()
+            .chain(
+                capture
+                    .capture
+                    .scope_coverage
+                    .iter()
+                    .filter_map(|coverage| coverage.detail.as_deref()),
+            )
+            .collect();
+        assert!(
+            !sensitive.is_empty(),
+            "fixture must supply adapter free text for this test to mean anything"
+        );
         let messages = findings
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs`
around lines 897 - 914, Add precondition assertions in the privacy test around
the existing checks to verify the fixture contains each expected sensitive
value: the adapter error message, at least one scope-coverage detail, and the
identity/profile-path text represented by “jrivera” and “C:\Users”. Keep the
existing non-leak assertions unchanged so the test fails both when sensitive
fixture data disappears and when it is exposed in finding messages.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs`:
- Around line 897-914: Add precondition assertions in the privacy test around
the existing checks to verify the fixture contains each expected sensitive
value: the adapter error message, at least one scope-coverage detail, and the
identity/profile-path text represented by “jrivera” and “C:\Users”. Keep the
existing non-leak assertions unchanged so the test fails both when sensitive
fixture data disappears and when it is exposed in finding messages.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2aefa967-7206-4f9b-bc04-f8fb8ea51bb3

📥 Commits

Reviewing files that changed from the base of the PR and between f5a5feb and e511b31.

📒 Files selected for processing (6)
  • crates/cmtraceopen-parser/src/collector/profile.rs
  • crates/cmtraceopen-parser/src/collector/profile_data.json
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/legacy.rs
  • crates/cmtraceopen-parser/tests/company_portal_windows_package_state.rs
  • src-tauri/src/collector/mod.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/cmtraceopen-parser/src/collector/profile.rs
  • src-tauri/src/collector/mod.rs
  • crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/findings.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated no new comments.

Suppressed comments (2)

crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs:71

  • parse_package_state_capture currently deserializes PackageStateCapture with #[serde(default)], so documents that omit required fields like capture or packages will silently default them (e.g., packages: []). If such a partially-shaped document also reports commandStatus: completed and scopeCoverage: complete, derive_package_state_findings can incorrectly claim package absence based on missing data. Consider validating that capture is an object and packages is an array before deserializing, and treat missing fields as InvalidBody.
    let mut capture: PackageStateCapture = serde_json::from_value(document.clone()).map_err(
        |error| PackageStateError::InvalidBody {
            version: schema_version,
            detail: error.to_string(),
        },
    )?;
    capture.schema_version = schema_version;
    capture.raw_document = None;
    preserve_unknown_package_fields(&document, &mut capture);
    Ok(capture)

crates/cmtraceopen-parser/src/collector/profile_data.json:1304

  • In the embedded PowerShell adapter command, several values are forced through [string]... (e.g. publisher = [string]$p.Publisher, code = [string]$_.FullyQualifiedErrorId). In PowerShell, [string]$null becomes an empty string, so optional fields will serialize as "" instead of null/absent. Since the capture schema models these as optional (Option<String>), emitting "" can blur the distinction between “unknown/unreported” and “present but empty”, and it can create misleading downstream facts.
            "arguments": ["-NoProfile", "-Command", "$ErrorActionPreference = 'Stop'; $cc = { param($v) $s = [string]$v; if ($s.Length -gt 0) { $s.Substring(0,1).ToLowerInvariant() + $s.Substring(1) } else { '' } }; $rows = @(); $commandStatus = 'completed'; $coverage = 'complete'; $captureError = $null; try { $rows = @(Get-AppxPackage -AllUsers -ErrorAction Stop | Where-Object { $_.Name -match 'CompanyPortal|IntuneCompanyPortal|Authenticator' }) } catch { $rows = @(); $msg = [string]$_.Exception.Message; if ($_.Exception -is [System.UnauthorizedAccessException] -or ([string]$_.FullyQualifiedErrorId) -match 'UnauthorizedAccess|AccessDenied' -or (($_.Exception.HResult -band 0xFFFFFFFF) -eq 0x80070005) -or $msg -match 'denied|elevated|administrator') { $commandStatus = 'accessDenied'; $coverage = 'denied' } else { $commandStatus = 'failed'; $coverage = 'failed' }; $captureError = [ordered]@{ code = [string]$_.FullyQualifiedErrorId; message = $msg } }; $packages = @(); foreach ($p in $rows) { $states = @($p.PackageUserInformation | Where-Object { $_ -ne $null }); $installState = 'notInstalled'; if ($states | Where-Object { $_.InstallState -eq 'Installed' }) { $installState = 'installed' } elseif ($states | Where-Object { $_.InstallState -eq 'Staged' }) { $installState = 'staged' }; $app = 'other'; if ($p.Name -match 'CompanyPortal') { $app = 'companyPortal' } elseif ($p.Name -match 'Authenticator') { $app = 'authenticator' }; $location = $null; if ($p.InstallLocation) { $location = [ordered]@{ value = [string]$p.InstallLocation; sensitivity = 'sensitive' } }; $packages += [ordered]@{ name = [string]$p.Name; familyName = [string]$p.PackageFamilyName; fullName = [string]$p.PackageFullName; version = [string]$p.Version; architecture = (& $cc $p.Architecture); publisher = [string]$p.Publisher; signatureKind = (& $cc $p.SignatureKind); status = (& $cc $p.Status); installState = $installState; scopes = @('allUsers'); userRegistrationCount = $states.Count; installLocation = $location; app = $app } }; $doc = [ordered]@{ schemaVersion = 1; capture = [ordered]@{ capturedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); adapterVersion = 'cmtraceopen-collector-appx/1'; commandStatus = $commandStatus; windowsBuild = [string][System.Environment]::OSVersion.Version; powerShellVersion = [string]$PSVersionTable.PSVersion; locale = [string](Get-Culture).Name; source = 'json'; scopeCoverage = @([ordered]@{ scope = 'allUsers'; status = $coverage; detail = $null }); error = $captureError }; packages = @($packages) }; $doc | ConvertTo-Json -Depth 6 -Compress"],

…king

Four CodeRabbit nitpicks on the package-state contract, each verified before
being accepted. One stale nitpick about the push_absence_findings doc comment
was already fixed in 92c1704 and is skipped.

camel_case_enum called expect on the deserialize result. The raw-preserving
enums it is used with accept any string, so it cannot fail today, but the bound
was only Deserialize: a later call with a strict enum would panic inside a pure
parser on untrusted text. It now requires Default and falls back. Default is
implemented for the four package enums as an empty Unknown, which is what
PackageRow::default already constructed by hand.

The redaction projection masked and pseudonymized on presence alone, ignoring
the sensitivity field that exists precisely to classify these values. Every
value wired up today is sensitive or restricted, so behavior is unchanged, but a
producer marking a value public would have had it discarded anyway. That is the
over-redaction this module documents itself as avoiding. Masking is now gated on
the classification.

Two test-quality fixes. The privacy test's assertions were all conditional, so a
fixture that stopped carrying sensitive text would leave the test passing while
proving nothing; it now asserts the fixture still carries identity and a profile
path before asserting no finding repeats them. The ordering assertion claimed
findings are emitted most-severe first, but sort_findings orders by kind rank
and id; the message now says what the code does.

Added an inline models test comparing the serialized PackageRow shape against
KNOWN_PACKAGE_ROW_FIELDS. That list is maintained by hand and decides what gets
folded into `raw`, so a field added to the struct but forgotten there would
appear twice in one capture.

Verified: cargo test --locked 1391 passed / 0 failed (1390 before); cargo clippy
--locked --all-targets -- -D warnings exit 0; cargo check --locked -p
cmtraceopen-parser --target wasm32-unknown-unknown exit 0; npx tsc --noEmit
exit 0.

Refs #367

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src-tauri/src/collector/mod.rs:43

  • This test runs the PowerShell adapter via Command::output() without applying the command item's configured timeout_secs. If the Get-AppxPackage call hangs (e.g., PowerShell host issues, AppX subsystem stalls), the Windows CI job can hang indefinitely instead of failing fast.

Consider spawning the process with stdout/stderr piped, polling with try_wait() until item.timeout_secs elapses, and killing the child on timeout (then failing the test with diagnostics).
crates/cmtraceopen-parser/src/collector/profile_data.json:1304

  • The $cc helper only lowercases the first character to convert PowerShell’s PascalCase tokens into the schema’s camelCase. That breaks all-caps architecture values: e.g., ARM64 becomes aRM64, which will deserialize as PackageArchitecture::Unknown("aRM64") instead of the intended arm64 variant.

To keep ARM/ARM64 devices on the typed variants, tweak $cc to fully-lowercase strings that are entirely [A-Z0-9]+ (or otherwise special-case ARM/ARM64) while preserving the current behavior for NeedsRemediation-style tokens.

            "arguments": ["-NoProfile", "-Command", "$ErrorActionPreference = 'Stop'; $cc = { param($v) $s = [string]$v; if ($s.Length -gt 0) { $s.Substring(0,1).ToLowerInvariant() + $s.Substring(1) } else { '' } }; $rows = @(); $commandStatus = 'completed'; $coverage = 'complete'; $captureError = $null; try { $rows = @(Get-AppxPackage -AllUsers -ErrorAction Stop | Where-Object { $_.Name -match 'CompanyPortal|IntuneCompanyPortal|Authenticator' }) } catch { $rows = @(); $msg = [string]$_.Exception.Message; if ($_.Exception -is [System.UnauthorizedAccessException] -or ([string]$_.FullyQualifiedErrorId) -match 'UnauthorizedAccess|AccessDenied' -or (($_.Exception.HResult -band 0xFFFFFFFF) -eq 0x80070005) -or $msg -match 'denied|elevated|administrator') { $commandStatus = 'accessDenied'; $coverage = 'denied' } else { $commandStatus = 'failed'; $coverage = 'failed' }; $captureError = [ordered]@{ code = [string]$_.FullyQualifiedErrorId; message = $msg } }; $packages = @(); foreach ($p in $rows) { $states = @($p.PackageUserInformation | Where-Object { $_ -ne $null }); $installState = 'notInstalled'; if ($states | Where-Object { $_.InstallState -eq 'Installed' }) { $installState = 'installed' } elseif ($states | Where-Object { $_.InstallState -eq 'Staged' }) { $installState = 'staged' }; $app = 'other'; if ($p.Name -match 'CompanyPortal') { $app = 'companyPortal' } elseif ($p.Name -match 'Authenticator') { $app = 'authenticator' }; $location = $null; if ($p.InstallLocation) { $location = [ordered]@{ value = [string]$p.InstallLocation; sensitivity = 'sensitive' } }; $packages += [ordered]@{ name = [string]$p.Name; familyName = [string]$p.PackageFamilyName; fullName = [string]$p.PackageFullName; version = [string]$p.Version; architecture = (& $cc $p.Architecture); publisher = [string]$p.Publisher; signatureKind = (& $cc $p.SignatureKind); status = (& $cc $p.Status); installState = $installState; scopes = @('allUsers'); userRegistrationCount = $states.Count; installLocation = $location; app = $app } }; $doc = [ordered]@{ schemaVersion = 1; capture = [ordered]@{ capturedAtUtc = (Get-Date).ToUniversalTime().ToString('o'); adapterVersion = 'cmtraceopen-collector-appx/1'; commandStatus = $commandStatus; windowsBuild = [string][System.Environment]::OSVersion.Version; powerShellVersion = [string]$PSVersionTable.PSVersion; locale = [string](Get-Culture).Name; source = 'json'; scopeCoverage = @([ordered]@{ scope = 'allUsers'; status = $coverage; detail = $null }); error = $captureError }; packages = @($packages) }; $doc | ConvertTo-Json -Depth 6 -Compress"],

@adamgell

Copy link
Copy Markdown
Owner Author

Review cycle converged

Final head 2b9d3d5a. All 16 CI checks green, zero unresolved review threads, reviewDecision: APPROVED.

Cycle: /code-review → CodeRabbit → Copilot, repeated until clean. Fourteen findings were accepted and fixed. Every fix carries a test that I confirmed fails against the previous implementation and passes against the fix, so none of them are tests written to agree with the code.

The two that mattered most:

  • A live privacy leak. PackageStateFinding is a separate type that redacted_package_state_export never sees, so the adapter error message and scope-coverage detail interpolated into finding messages bypassed redaction entirely. The access-denied fixture's message contains C:\Users\jrivera, so this was reachable, not theoretical. Findings now carry the stable error code only.
  • Absence decided per app rather than per scope. A registration present only in currentUser never reported absence from a completely enumerated allUsers, which is exactly the per-user-only deployment failure this contract exists to surface.

Copilot's final review at 2b9d3d5a: "Copilot reviewed 33 out of 33 changed files in this pull request and generated no new comments."

The Windows-gated adapter test has now actually executed. From the ESP Diagnostics (Windows) job log, step "Test full Windows application workspace":

test collector::appx_adapter_windows_tests::appx_adapter_emits_a_parseable_package_state_capture ... ok

Flagging one thing for whoever touches this area next: Rust MSRV (1.88 / windows-latest) does not exercise that test. It runs cargo +1.88 check --workspace --all-features without --all-targets, so it never compiles cfg(test) code. ESP Diagnostics (Windows) is the job that runs it.

Local gates on 2b9d3d5a, clean tree: cargo test --locked 1391 passed / 0 failed (baseline on main is 1362); cargo clippy --locked --all-targets -- -D warnings exit 0; cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown exit 0; npx tsc --noEmit exit 0.

Two unrelated defects were found while working this PR and are deliberately not fixed here, both described in the PR body: the label-new-items.yml permissions bug that fails on every newly opened PR, and a temp-directory race in src-tauri/tests/cmtlog_parser.rs that flakes roughly one full-suite run in five.

@adamgell

Copy link
Copy Markdown
Owner Author

@coderabbitai review

Requesting a re-review anchored to 2b9d3d5a. Your approval landed on e511b31e, and the one commit since then implements four of your own nitpicks (the camel_case_enum panic, sensitivity-gated masking, the vacuous privacy assertions, and the KNOWN_PACKAGE_ROW_FIELDS sync guard). Confirming that final commit is clean rather than inferring it from the earlier approval.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

@adamgell I will re-review pull request #395 at 2b9d3d5a. I will verify the four follow-up fixes independently from the prior approval.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@adamgell
adamgell merged commit a207376 into main Jul 31, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request feature New feature intune Microsoft Intune related parser Log parser related portal Company Portal related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Company Portal Windows: define deterministic package-state evidence

2 participants