Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions crates/cmtraceopen-parser/src/collector/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,64 @@ mod tests {
item.source_pattern
);
}

#[test]
fn embedded_profile_appx_item_emits_versioned_json_capture() {
// Contract guard for issue #367: the Company Portal / Authenticator AppX
// adapter must emit the versioned JSON capture schema consumed by
// intune::portal::windows::company_portal::package_state. Format-List is a
// display rendering whose labels, order, and wrapping vary by locale and
// host version, so it must never be the canonical capture format.
let profile = CollectionProfile::embedded();
let item = profile
.commands
.iter()
.find(|i| i.id == "appx-info")
.expect("profile must contain the AppX package-state adapter");

assert_eq!(item.file_name, "appx-intune-packages.json");

let command = item.arguments.join(" ");
assert!(
command.contains("ConvertTo-Json"),
"adapter must serialize with ConvertTo-Json: {command}"
);
assert!(
!command.contains("Format-List"),
"adapter must not emit display-formatted text: {command}"
);
for required in [
"schemaVersion",
"capturedAtUtc",
"commandStatus",
"scopeCoverage",
"accessDenied",
"-Compress",
// Pin the depth, not just the flag. A lower value would silently
// truncate capture.scopeCoverage[] and packages[].installLocation.
"-Depth 6",
] {
assert!(
command.contains(required),
"adapter command is missing {required}: {command}"
);
}
Comment thread
adamgell marked this conversation as resolved.
assert!(
command.contains("try {") && command.contains("catch {"),
"adapter must wrap the query so a denied -AllUsers scope is reported \
rather than returning an empty success: {command}"
);
assert!(
command.is_ascii(),
"adapter command must be ASCII; PowerShell 5.1 mis-parses non-ASCII literals"
);
// Every other JSON-emitting command in this profile carries a "json"
// hint, and the evidence-bundle dialog searches on it. Omitting it here
// would leave this artifact undiscoverable by that search.
assert!(
item.parse_hints.iter().any(|hint| hint == "json"),
"JSON-emitting adapters must declare a json parse hint: {:?}",
item.parse_hints
);
}
}
7 changes: 4 additions & 3 deletions crates/cmtraceopen-parser/src/collector/profile_data.json
Original file line number Diff line number Diff line change
Expand Up @@ -1299,11 +1299,12 @@
{
"id": "appx-info",
"family": "general",
"parseHints": ["json", "package-state"],
"command": "powershell.exe",
"arguments": ["-NoProfile", "-Command", "Get-AppxPackage -AllUsers | Where-Object { $_.Name -match 'CompanyPortal|IntuneCompanyPortal|Authenticator' } | Format-List Name, Version, PackageFullName, Status, SignatureKind"],
"fileName": "appx-intune-packages.txt",
"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"],
"fileName": "appx-intune-packages.json",
"timeoutSecs": 30,
"notes": "Company Portal and Authenticator AppX package info."
"notes": "Company Portal and Authenticator AppX package state as the versioned JSON capture schema (schemaVersion 1) consumed by intune::portal::windows::company_portal::package_state. Emits commandStatus and per-scope coverage so a denied -AllUsers query is reported as accessDenied/denied instead of an empty success."
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
{
"id": "mdm-diag-tool",
Expand Down
47 changes: 3 additions & 44 deletions crates/cmtraceopen-parser/src/esp/models.rs
Original file line number Diff line number Diff line change
@@ -1,47 +1,6 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer};

macro_rules! raw_preserving_string_enum {
(
$(#[$meta:meta])*
pub enum $name:ident {
$($variant:ident => $wire_value:literal),+ $(,)?
}
) => {
$(#[$meta])*
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum $name {
$($variant,)+
Unknown(String),
}

impl Serialize for $name {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let value = match self {
$(Self::$variant => $wire_value,)+
Self::Unknown(raw) => raw.as_str(),
};

serializer.serialize_str(value)
}
}

impl<'de> Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Ok(match raw.as_str() {
$($wire_value => Self::$variant,)+
_ => Self::Unknown(raw),
})
}
}
};
}
use serde::{Deserialize, Serialize};

use crate::wire::raw_preserving_string_enum;

pub const ESP_DIAGNOSTICS_SCHEMA_VERSION: u32 = 1;

Expand Down
1 change: 1 addition & 0 deletions crates/cmtraceopen-parser/src/intune/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
8 changes: 8 additions & 0 deletions crates/cmtraceopen-parser/src/intune/portal/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//! Company Portal evidence surfaces.
//!
//! Company Portal spans sign-in, enrollment, the app catalog, compliance, sync,
//! device actions, and support, so it is a first-class surface rather than a
//! sub-case of IME or ESP. Platform-specific contracts live under the matching
//! platform module.

pub mod windows;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
//! Windows Company Portal evidence contracts.

pub mod package_state;
Loading