use super::models::{MacosPackageFiles, MacosPackageInfo, MacosPackagesResult};
use regex::Regex;
use std::sync::OnceLock;
fn pkg_id_re() -> &'static Regex {
static CELL: OnceLock<Regex> = OnceLock::new();
CELL.get_or_init(|| Regex::new(r"^[a-zA-Z0-9._-]+$").unwrap())
}
pub fn parse_pkgutil_pkgs_output(output: &str) -> Vec<String> {
output
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
}
pub fn filter_microsoft_packages(packages: &[String]) -> Vec<String> {
packages
.iter()
.filter(|pkg| {
let lower = pkg.to_lowercase();
lower.contains("microsoft") || lower == "com.apple.pkg.mobiledevice"
})
.cloned()
.collect()
}
pub fn parse_pkgutil_pkg_info(output: &str) -> MacosPackageInfo {
let mut package_id = String::new();
let mut version = String::new();
let mut volume: Option<String> = None;
let mut location: Option<String> = None;
let mut install_time: Option<String> = None;
for line in output.lines() {
let line = line.trim();
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
let value = value.trim();
match key {
"package-id" => package_id = value.to_string(),
"version" => version = value.to_string(),
"volume" => volume = Some(value.to_string()),
"location" => location = Some(value.to_string()),
"install-time" => install_time = Some(value.to_string()),
_ => {}
}
}
}
MacosPackageInfo {
package_id,
version,
volume,
location,
install_time,
}
}
pub fn parse_pkgutil_files(output: &str, package_id: &str) -> MacosPackageFiles {
let files: Vec<String> = output
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
let file_count = files.len();
MacosPackageFiles {
package_id: package_id.to_string(),
files,
file_count,
}
}
pub fn validate_package_id(id: &str) -> Result<(), crate::error::AppError> {
if pkg_id_re().is_match(id) {
Ok(())
} else {
Err(crate::error::AppError::InvalidInput(format!(
"Invalid package ID '{}': must contain only alphanumeric characters, dots, hyphens, and underscores",
id
)))
}
}
#[cfg(target_os = "macos")]
pub fn list_packages_impl() -> Result<MacosPackagesResult, crate::error::AppError> {
use std::process::Command;
log::info!("Listing installed packages via pkgutil");
let output = Command::new("pkgutil")
.arg("--pkgs")
.output()
.map_err(crate::error::AppError::Io)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(crate::error::AppError::Internal(format!(
"pkgutil --pkgs failed: {}",
stderr
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let all_packages = parse_pkgutil_pkgs_output(&stdout);
let total_count = all_packages.len();
let ms_ids = filter_microsoft_packages(&all_packages);
let microsoft_count = ms_ids.len();
let mut packages: Vec<MacosPackageInfo> = Vec::new();
for pkg_id in &ms_ids {
let info_output = Command::new("pkgutil")
.args(["--pkg-info", pkg_id])
.output();
match info_output {
Ok(out) if out.status.success() => {
let info_str = String::from_utf8_lossy(&out.stdout);
packages.push(parse_pkgutil_pkg_info(&info_str));
}
Ok(out) => {
log::warn!(
"pkgutil --pkg-info {} exited {}: {}",
pkg_id,
out.status,
String::from_utf8_lossy(&out.stderr)
);
packages.push(MacosPackageInfo {
package_id: pkg_id.clone(),
version: String::new(),
volume: None,
location: None,
install_time: None,
});
}
Err(e) => {
log::warn!("Failed to get info for {}: {}", pkg_id, e);
}
}
}
Ok(MacosPackagesResult {
packages,
total_count,
microsoft_count,
})
}
#[cfg(target_os = "macos")]
pub fn get_package_info_impl(package_id: &str) -> Result<MacosPackageInfo, crate::error::AppError> {
use std::process::Command;
validate_package_id(package_id)?;
log::info!("Getting package info for: {}", package_id);
let output = Command::new("pkgutil")
.args(["--pkg-info", package_id])
.output()
.map_err(crate::error::AppError::Io)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(crate::error::AppError::Internal(format!(
"pkgutil --pkg-info {} failed: {}",
package_id, stderr
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_pkgutil_pkg_info(&stdout))
}
#[cfg(target_os = "macos")]
pub fn get_package_files_impl(
package_id: &str,
) -> Result<MacosPackageFiles, crate::error::AppError> {
use std::process::Command;
validate_package_id(package_id)?;
log::info!("Getting package files for: {}", package_id);
let output = Command::new("pkgutil")
.args(["--files", package_id])
.output()
.map_err(crate::error::AppError::Io)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(crate::error::AppError::Internal(format!(
"pkgutil --files {} failed: {}",
package_id, stderr
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_pkgutil_files(&stdout, package_id))
}
#[cfg(not(target_os = "macos"))]
pub fn list_packages_impl() -> Result<MacosPackagesResult, crate::error::AppError> {
Err(crate::error::AppError::PlatformUnsupported(
"macOS Diagnostics is only available on macOS.".to_string(),
))
}
#[cfg(not(target_os = "macos"))]
pub fn get_package_info_impl(
_package_id: &str,
) -> Result<MacosPackageInfo, crate::error::AppError> {
Err(crate::error::AppError::PlatformUnsupported(
"macOS Diagnostics is only available on macOS.".to_string(),
))
}
#[cfg(not(target_os = "macos"))]
pub fn get_package_files_impl(
_package_id: &str,
) -> Result<MacosPackageFiles, crate::error::AppError> {
Err(crate::error::AppError::PlatformUnsupported(
"macOS Diagnostics is only available on macOS.".to_string(),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pkgutil_pkgs_output() {
let input = "com.apple.pkg.Core\ncom.microsoft.wdav\ncom.microsoft.teams\n";
let pkgs = parse_pkgutil_pkgs_output(input);
assert_eq!(pkgs.len(), 3);
assert_eq!(pkgs[0], "com.apple.pkg.Core");
assert_eq!(pkgs[1], "com.microsoft.wdav");
}
#[test]
fn test_parse_pkgutil_pkgs_empty() {
let pkgs = parse_pkgutil_pkgs_output("");
assert!(pkgs.is_empty());
}
#[test]
fn test_filter_microsoft_packages() {
let all = vec![
"com.apple.pkg.Core".to_string(),
"com.microsoft.wdav".to_string(),
"com.Microsoft.Teams".to_string(),
"com.apple.pkg.MobileDevice".to_string(),
"com.google.chrome".to_string(),
];
let filtered = filter_microsoft_packages(&all);
assert_eq!(filtered.len(), 3);
assert!(filtered.contains(&"com.microsoft.wdav".to_string()));
assert!(filtered.contains(&"com.Microsoft.Teams".to_string()));
assert!(filtered.contains(&"com.apple.pkg.MobileDevice".to_string()));
}
#[test]
fn test_parse_pkgutil_pkg_info() {
let input = "package-id: com.microsoft.wdav\nversion: 101.25012.0002\nvolume: /\nlocation: /\ninstall-time: 1710789785\n";
let info = parse_pkgutil_pkg_info(input);
assert_eq!(info.package_id, "com.microsoft.wdav");
assert_eq!(info.version, "101.25012.0002");
assert_eq!(info.volume.as_deref(), Some("/"));
assert_eq!(info.location.as_deref(), Some("/"));
assert_eq!(info.install_time.as_deref(), Some("1710789785"));
}
#[test]
fn test_parse_pkgutil_files() {
let input = "usr/local/bin/mdatp\nusr/local/share/mdatp/config.json\n";
let files = parse_pkgutil_files(input, "com.microsoft.wdav");
assert_eq!(files.package_id, "com.microsoft.wdav");
assert_eq!(files.file_count, 2);
assert_eq!(files.files[0], "usr/local/bin/mdatp");
}
#[test]
fn test_validate_package_id_valid() {
assert!(validate_package_id("com.microsoft.wdav").is_ok());
assert!(validate_package_id("com.apple.pkg.Core").is_ok());
assert!(validate_package_id("my-package_1.0").is_ok());
}
#[test]
fn test_validate_package_id_invalid() {
assert!(validate_package_id("com.evil; rm -rf /").is_err());
assert!(validate_package_id("$(whoami)").is_err());
assert!(validate_package_id("pkg id with spaces").is_err());
assert!(validate_package_id("").is_err());
}
}