Skip to main content

nex_pkg/
hardware_inventory.rs

1use std::process::Command;
2
3use anyhow::{bail, Context, Result};
4use serde::{Deserialize, Serialize};
5
6pub const HARDWARE_INVENTORY_SCHEMA_V1: &str = "io.styrene.nex.hardware-inventory.v1";
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(rename_all = "kebab-case")]
10pub enum HardwarePlatform {
11    Darwin,
12    Linux,
13    Unknown,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "camelCase")]
18pub struct HardwareInventory {
19    pub schema: String,
20    pub platform: HardwarePlatform,
21    pub arch: String,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub vendor: Option<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub model: Option<String>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub model_name: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub cpu: Option<HardwareCpuSummary>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub memory: Option<HardwareMemorySummary>,
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub disks: Vec<HardwareDisk>,
34    pub evidence: HardwareEvidence,
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub warnings: Vec<HardwareWarning>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct HardwareCpuSummary {
41    pub summary: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct HardwareMemorySummary {
46    pub summary: String,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "camelCase")]
51pub struct HardwareProfileMatchReport {
52    pub schema: String,
53    pub inventory_schema: String,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub requested_purpose: Option<String>,
56    pub hardware_class: HardwareClassCandidate,
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub recommendations: Vec<HardwareProfileRecommendation>,
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub warnings: Vec<HardwareWarning>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "camelCase")]
65pub struct HardwareProfileRecommendation {
66    pub profile_ref: String,
67    pub score: u8,
68    pub confidence: ClassificationConfidence,
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub reasons: Vec<String>,
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub missing: Vec<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "kebab-case")]
77pub enum HardwareClassCandidate {
78    MacosArm64,
79    MacosAmd64,
80    Amd64AppleT2,
81    Amd64Generic,
82    Arm64Generic,
83    Unknown,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
87#[serde(rename_all = "camelCase")]
88pub struct DiskAttestationReport {
89    pub schema: String,
90    pub disk: HardwareDisk,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(rename_all = "camelCase")]
95pub struct HardwareDisk {
96    pub id: String,
97    pub path: String,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub whole_disk: Option<bool>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub size_bytes: Option<u64>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub internal: Option<bool>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub removable: Option<bool>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub ejectable: Option<bool>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub solid_state: Option<bool>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub bus: Option<String>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub vendor: Option<String>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub model: Option<String>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub media_name: Option<String>,
118    pub classification: DiskClassification,
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub evidence_sources: Vec<String>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124#[serde(rename_all = "camelCase")]
125pub struct DiskClassification {
126    pub target_attestation: TargetAttestationCandidate,
127    pub destructive_default: DestructiveDefault,
128    pub confidence: ClassificationConfidence,
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub reasons: Vec<String>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
134#[serde(rename_all = "kebab-case")]
135pub enum TargetAttestationCandidate {
136    ExternalUsbSsd,
137    ExternalThunderboltSsd,
138    InternalAppleStorage,
139    Unknown,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143#[serde(rename_all = "kebab-case")]
144pub enum DestructiveDefault {
145    AllowedWithAttestation,
146    Forbidden,
147    RequiresOperatorAttestation,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
151#[serde(rename_all = "kebab-case")]
152pub enum ClassificationConfidence {
153    Strong,
154    Weak,
155    Unknown,
156}
157
158#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
159pub struct HardwareEvidence {
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub commands: Vec<String>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165pub struct HardwareWarning {
166    pub code: String,
167    pub message: String,
168}
169
170#[derive(Debug, Clone, Default, PartialEq, Eq)]
171pub struct DiskEvidence {
172    pub id: String,
173    pub path: String,
174    pub whole_disk: Option<bool>,
175    pub size_bytes: Option<u64>,
176    pub internal: Option<bool>,
177    pub removable: Option<bool>,
178    pub ejectable: Option<bool>,
179    pub solid_state: Option<bool>,
180    pub bus: Option<String>,
181    pub media_name: Option<String>,
182    pub io_registry_entry_name: Option<String>,
183    pub device_tree_path: Option<String>,
184    pub evidence_sources: Vec<String>,
185}
186
187pub fn scan_host() -> Result<HardwareInventory> {
188    if cfg!(target_os = "macos") {
189        return collect_darwin_inventory();
190    }
191    Ok(degraded_inventory(
192        HardwarePlatform::Unknown,
193        "UNSUPPORTED_PLATFORM",
194        "Live hardware scanning is not implemented for this platform yet.",
195    ))
196}
197
198pub fn attest_disk(disk: &str) -> Result<DiskAttestationReport> {
199    let inventory = scan_host()?;
200    attest_disk_from_inventory(inventory, disk)
201}
202
203pub fn attest_disk_from_inventory(
204    inventory: HardwareInventory,
205    disk: &str,
206) -> Result<DiskAttestationReport> {
207    let normalized = normalize_disk_selector(disk);
208    let disk = inventory
209        .disks
210        .into_iter()
211        .find(|candidate| {
212            candidate.id == normalized
213                || candidate.path == disk
214                || candidate.path.strip_prefix("/dev/") == Some(normalized.as_str())
215        })
216        .with_context(|| format!("disk {disk:?} was not found in hardware inventory"))?;
217    Ok(DiskAttestationReport {
218        schema: "io.styrene.nex.disk-attestation.v1".to_string(),
219        disk,
220    })
221}
222
223pub fn match_profiles(
224    inventory: &HardwareInventory,
225    purpose: Option<&str>,
226) -> HardwareProfileMatchReport {
227    let hardware_class = classify_hardware(inventory);
228    let mut recommendations = Vec::new();
229    if let Some(profile_ref) = starter_profile_for(&hardware_class, purpose) {
230        recommendations.push(HardwareProfileRecommendation {
231            profile_ref,
232            score: if purpose.is_some() { 85 } else { 70 },
233            confidence: if purpose.is_some() {
234                ClassificationConfidence::Strong
235            } else {
236                ClassificationConfidence::Weak
237            },
238            reasons: vec![format!(
239                "inventory matches hardware class {hardware_class:?}"
240            )],
241            missing: if purpose.is_some() {
242                Vec::new()
243            } else {
244                vec!["purpose not supplied".to_string()]
245            },
246        });
247    }
248    HardwareProfileMatchReport {
249        schema: "io.styrene.nex.hardware-profile-match.v1".to_string(),
250        inventory_schema: inventory.schema.clone(),
251        requested_purpose: purpose.map(ToString::to_string),
252        hardware_class,
253        recommendations,
254        warnings: Vec::new(),
255    }
256}
257
258fn classify_hardware(inventory: &HardwareInventory) -> HardwareClassCandidate {
259    match inventory.platform {
260        HardwarePlatform::Darwin if inventory.arch == "aarch64" => {
261            HardwareClassCandidate::MacosArm64
262        }
263        HardwarePlatform::Darwin if inventory.arch == "x86_64" => {
264            if inventory
265                .model
266                .as_deref()
267                .is_some_and(|model| model.starts_with("MacBookPro"))
268            {
269                HardwareClassCandidate::Amd64AppleT2
270            } else {
271                HardwareClassCandidate::MacosAmd64
272            }
273        }
274        HardwarePlatform::Linux if inventory.arch == "aarch64" => {
275            HardwareClassCandidate::Arm64Generic
276        }
277        HardwarePlatform::Linux if inventory.arch == "x86_64" => {
278            HardwareClassCandidate::Amd64Generic
279        }
280        _ => HardwareClassCandidate::Unknown,
281    }
282}
283
284fn starter_profile_for(
285    hardware_class: &HardwareClassCandidate,
286    purpose: Option<&str>,
287) -> Option<String> {
288    let purpose = purpose.unwrap_or("dev");
289    let hardware = match hardware_class {
290        HardwareClassCandidate::MacosArm64 => "macos-arm64",
291        HardwareClassCandidate::MacosAmd64 => "macos-amd64",
292        HardwareClassCandidate::Amd64AppleT2 => "amd64-apple-t2",
293        HardwareClassCandidate::Amd64Generic => "amd64",
294        HardwareClassCandidate::Arm64Generic => "arm64",
295        HardwareClassCandidate::Unknown => return None,
296    };
297    Some(format!("starter/{hardware}-{purpose}"))
298}
299
300fn normalize_disk_selector(disk: &str) -> String {
301    disk.strip_prefix("/dev/").unwrap_or(disk).to_string()
302}
303
304fn degraded_inventory(
305    platform: HardwarePlatform,
306    code: impl Into<String>,
307    message: impl Into<String>,
308) -> HardwareInventory {
309    HardwareInventory {
310        schema: HARDWARE_INVENTORY_SCHEMA_V1.to_string(),
311        platform,
312        arch: std::env::consts::ARCH.to_string(),
313        vendor: None,
314        model: None,
315        model_name: None,
316        cpu: None,
317        memory: None,
318        disks: Vec::new(),
319        evidence: HardwareEvidence::default(),
320        warnings: vec![HardwareWarning {
321            code: code.into(),
322            message: message.into(),
323        }],
324    }
325}
326
327fn collect_darwin_inventory() -> Result<HardwareInventory> {
328    let hardware_json = run_capture("system_profiler", &["SPHardwareDataType", "-json"])?;
329    let hardware = parse_system_profiler_hardware_json(&hardware_json)?;
330
331    let disk_list_plist = run_capture("diskutil", &["list", "-plist"])?;
332    let whole_disks = parse_diskutil_whole_disks(&disk_list_plist)?;
333    let mut disks = Vec::new();
334    let mut warnings = Vec::new();
335    for disk in whole_disks {
336        match run_capture("diskutil", &["info", "-plist", disk.as_str()]) {
337            Ok(info) => match parse_diskutil_info_plist(&info) {
338                Ok(disk) => disks.push(disk),
339                Err(error) => warnings.push(HardwareWarning {
340                    code: "DISKUTIL_INFO_PARSE_FAILED".to_string(),
341                    message: format!("Failed to parse diskutil info for {disk}: {error:#}"),
342                }),
343            },
344            Err(error) => warnings.push(HardwareWarning {
345                code: "DISKUTIL_INFO_FAILED".to_string(),
346                message: format!("Failed to inspect {disk}: {error:#}"),
347            }),
348        }
349    }
350
351    Ok(HardwareInventory {
352        schema: HARDWARE_INVENTORY_SCHEMA_V1.to_string(),
353        platform: HardwarePlatform::Darwin,
354        arch: std::env::consts::ARCH.to_string(),
355        vendor: Some("Apple".to_string()),
356        model: hardware.machine_model,
357        model_name: hardware.machine_name,
358        cpu: hardware
359            .chip_type
360            .map(|summary| HardwareCpuSummary { summary }),
361        memory: hardware
362            .physical_memory
363            .map(|summary| HardwareMemorySummary { summary }),
364        disks,
365        evidence: HardwareEvidence {
366            commands: vec![
367                "system_profiler SPHardwareDataType -json".to_string(),
368                "diskutil list -plist".to_string(),
369                "diskutil info -plist <disk>".to_string(),
370            ],
371        },
372        warnings,
373    })
374}
375
376fn run_capture(program: &str, args: &[&str]) -> Result<String> {
377    let output = Command::new(program)
378        .args(args)
379        .output()
380        .with_context(|| format!("running {program}"))?;
381    if !output.status.success() {
382        bail!(
383            "{} {} failed with status {}: {}",
384            program,
385            args.join(" "),
386            output.status,
387            String::from_utf8_lossy(&output.stderr).trim()
388        );
389    }
390    String::from_utf8(output.stdout).with_context(|| format!("decoding {program} stdout as UTF-8"))
391}
392
393#[derive(Debug, Deserialize)]
394#[serde(rename_all = "PascalCase")]
395struct DiskutilList {
396    #[serde(default, rename = "AllDisksAndPartitions")]
397    all_disks_and_partitions: Vec<DiskutilListDisk>,
398}
399
400#[derive(Debug, Deserialize)]
401#[serde(rename_all = "PascalCase")]
402struct DiskutilListDisk {
403    #[serde(rename = "DeviceIdentifier")]
404    device_identifier: String,
405    #[serde(default, rename = "Content")]
406    content: Option<String>,
407}
408
409pub fn parse_diskutil_whole_disks(plist: &str) -> Result<Vec<String>> {
410    let list: DiskutilList =
411        plist::from_bytes(plist.as_bytes()).context("decoding diskutil list plist")?;
412    Ok(list
413        .all_disks_and_partitions
414        .into_iter()
415        .filter(|disk| {
416            disk.content
417                .as_deref()
418                .is_some_and(|content| content.ends_with("partition_scheme"))
419        })
420        .map(|disk| disk.device_identifier)
421        .collect())
422}
423
424#[derive(Debug, Deserialize)]
425#[serde(rename_all = "camelCase")]
426struct SystemProfilerHardware {
427    #[serde(rename = "SPHardwareDataType")]
428    sp_hardware_data_type: Vec<SystemProfilerHardwareItem>,
429}
430
431#[derive(Debug, Deserialize)]
432struct SystemProfilerHardwareItem {
433    machine_model: Option<String>,
434    machine_name: Option<String>,
435    chip_type: Option<String>,
436    physical_memory: Option<String>,
437}
438
439#[derive(Debug, Clone, PartialEq, Eq)]
440pub struct DarwinHardwareSummary {
441    pub machine_model: Option<String>,
442    pub machine_name: Option<String>,
443    pub chip_type: Option<String>,
444    pub physical_memory: Option<String>,
445}
446
447pub fn parse_system_profiler_hardware_json(input: &str) -> Result<DarwinHardwareSummary> {
448    let parsed: SystemProfilerHardware =
449        serde_json::from_str(input).context("decoding system_profiler hardware JSON")?;
450    let item = parsed
451        .sp_hardware_data_type
452        .into_iter()
453        .next()
454        .context("system_profiler hardware JSON did not contain SPHardwareDataType")?;
455    Ok(DarwinHardwareSummary {
456        machine_model: item.machine_model,
457        machine_name: item.machine_name,
458        chip_type: item.chip_type,
459        physical_memory: item.physical_memory,
460    })
461}
462
463#[derive(Debug, Deserialize)]
464#[serde(rename_all = "PascalCase")]
465struct DiskutilInfo {
466    #[serde(rename = "DeviceIdentifier")]
467    device_identifier: String,
468    #[serde(rename = "DeviceNode")]
469    device_node: Option<String>,
470    #[serde(rename = "WholeDisk")]
471    whole_disk: Option<bool>,
472    #[serde(rename = "Size")]
473    size: Option<u64>,
474    #[serde(rename = "Internal")]
475    internal: Option<bool>,
476    #[serde(rename = "RemovableMedia")]
477    removable_media: Option<bool>,
478    #[serde(rename = "Ejectable")]
479    ejectable: Option<bool>,
480    #[serde(rename = "SolidState")]
481    solid_state: Option<bool>,
482    #[serde(rename = "BusProtocol")]
483    bus_protocol: Option<String>,
484    #[serde(rename = "MediaName")]
485    media_name: Option<String>,
486    #[serde(rename = "IORegistryEntryName")]
487    io_registry_entry_name: Option<String>,
488    #[serde(rename = "DeviceTreePath")]
489    device_tree_path: Option<String>,
490}
491
492pub fn parse_diskutil_info_plist(input: &str) -> Result<HardwareDisk> {
493    let info: DiskutilInfo =
494        plist::from_bytes(input.as_bytes()).context("decoding diskutil info plist")?;
495    let evidence = DiskEvidence {
496        id: info.device_identifier.clone(),
497        path: info
498            .device_node
499            .clone()
500            .unwrap_or_else(|| format!("/dev/{}", info.device_identifier)),
501        whole_disk: info.whole_disk,
502        size_bytes: info.size,
503        internal: info.internal,
504        removable: info.removable_media,
505        ejectable: info.ejectable,
506        solid_state: info.solid_state,
507        bus: info.bus_protocol,
508        media_name: info.media_name,
509        io_registry_entry_name: info.io_registry_entry_name,
510        device_tree_path: info.device_tree_path,
511        evidence_sources: vec!["diskutil-info-plist".to_string()],
512    };
513    Ok(HardwareDisk {
514        id: evidence.id.clone(),
515        path: evidence.path.clone(),
516        whole_disk: evidence.whole_disk,
517        size_bytes: evidence.size_bytes,
518        internal: evidence.internal,
519        removable: evidence.removable,
520        ejectable: evidence.ejectable,
521        solid_state: evidence.solid_state,
522        bus: evidence.bus.clone(),
523        vendor: apple_storage_evidence(&evidence).then(|| "Apple".to_string()),
524        model: evidence.media_name.clone(),
525        media_name: evidence.media_name.clone(),
526        classification: classify_disk(&evidence),
527        evidence_sources: evidence.evidence_sources,
528    })
529}
530
531pub fn classify_disk(evidence: &DiskEvidence) -> DiskClassification {
532    let bus = evidence
533        .bus
534        .as_deref()
535        .unwrap_or_default()
536        .to_ascii_lowercase();
537    if evidence.internal == Some(true) && apple_storage_evidence(evidence) {
538        return DiskClassification {
539            target_attestation: TargetAttestationCandidate::InternalAppleStorage,
540            destructive_default: DestructiveDefault::Forbidden,
541            confidence: ClassificationConfidence::Strong,
542            reasons: vec![
543                "disk is internal".to_string(),
544                "disk evidence identifies Apple internal storage".to_string(),
545            ],
546        };
547    }
548
549    if evidence.internal == Some(false)
550        && evidence.solid_state == Some(true)
551        && (evidence.removable == Some(true) || evidence.ejectable == Some(true))
552        && bus.contains("usb")
553    {
554        return DiskClassification {
555            target_attestation: TargetAttestationCandidate::ExternalUsbSsd,
556            destructive_default: DestructiveDefault::AllowedWithAttestation,
557            confidence: ClassificationConfidence::Strong,
558            reasons: vec![
559                "disk is external or removable".to_string(),
560                "bus protocol is USB".to_string(),
561                "disk reports solid-state media".to_string(),
562            ],
563        };
564    }
565
566    if evidence.internal == Some(false)
567        && evidence.solid_state == Some(true)
568        && bus.contains("thunderbolt")
569    {
570        return DiskClassification {
571            target_attestation: TargetAttestationCandidate::ExternalThunderboltSsd,
572            destructive_default: DestructiveDefault::AllowedWithAttestation,
573            confidence: ClassificationConfidence::Strong,
574            reasons: vec![
575                "disk is external".to_string(),
576                "bus protocol is Thunderbolt".to_string(),
577                "disk reports solid-state media".to_string(),
578            ],
579        };
580    }
581
582    DiskClassification {
583        target_attestation: TargetAttestationCandidate::Unknown,
584        destructive_default: DestructiveDefault::RequiresOperatorAttestation,
585        confidence: ClassificationConfidence::Unknown,
586        reasons: vec!["disk evidence is insufficient for safe target attestation".to_string()],
587    }
588}
589
590fn apple_storage_evidence(evidence: &DiskEvidence) -> bool {
591    let haystack = [
592        evidence.bus.as_deref(),
593        evidence.media_name.as_deref(),
594        evidence.io_registry_entry_name.as_deref(),
595        evidence.device_tree_path.as_deref(),
596    ]
597    .into_iter()
598    .flatten()
599    .collect::<Vec<_>>()
600    .join(" ")
601    .to_ascii_lowercase();
602    haystack.contains("apple fabric")
603        || haystack.contains("apple ssd")
604        || haystack.contains("appleans")
605        || haystack.contains("ans@")
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    const SYSTEM_PROFILER_HARDWARE: &str = r#"{
613      "SPHardwareDataType": [
614        {
615          "_name": "hardware_overview",
616          "chip_type": "Apple M5 Max",
617          "machine_model": "Mac17,7",
618          "machine_name": "MacBook Pro",
619          "physical_memory": "128 GB",
620          "platform_UUID": "REDACTED",
621          "serial_number": "REDACTED"
622        }
623      ]
624    }"#;
625
626    const DISKUTIL_INFO_INTERNAL_APPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
627    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
628    <plist version="1.0"><dict>
629      <key>DeviceIdentifier</key><string>disk0</string>
630      <key>DeviceNode</key><string>/dev/disk0</string>
631      <key>WholeDisk</key><true/>
632      <key>Size</key><integer>4002222325760</integer>
633      <key>Internal</key><true/>
634      <key>SolidState</key><true/>
635      <key>BusProtocol</key><string>Apple Fabric</string>
636      <key>MediaName</key><string>APPLE SSD AP4096Z</string>
637      <key>IORegistryEntryName</key><string>APPLE SSD AP4096Z Media</string>
638      <key>DeviceTreePath</key><string>IODeviceTree:/arm-io@10F00000/ans@19600000/iop-ans-nub/AppleANS3CGv2Controller</string>
639      <key>RemovableMedia</key><false/>
640      <key>Ejectable</key><false/>
641    </dict></plist>"#;
642
643    #[test]
644    fn parses_system_profiler_hardware_summary_without_sensitive_fields() -> Result<()> {
645        let summary = parse_system_profiler_hardware_json(SYSTEM_PROFILER_HARDWARE)?;
646
647        assert_eq!(summary.machine_model.as_deref(), Some("Mac17,7"));
648        assert_eq!(summary.machine_name.as_deref(), Some("MacBook Pro"));
649        assert_eq!(summary.chip_type.as_deref(), Some("Apple M5 Max"));
650        assert_eq!(summary.physical_memory.as_deref(), Some("128 GB"));
651        Ok(())
652    }
653
654    #[test]
655    fn classifies_internal_apple_fabric_storage_as_forbidden() -> Result<()> {
656        let disk = parse_diskutil_info_plist(DISKUTIL_INFO_INTERNAL_APPLE)?;
657
658        assert_eq!(disk.id, "disk0");
659        assert_eq!(disk.internal, Some(true));
660        assert_eq!(
661            disk.classification.target_attestation,
662            TargetAttestationCandidate::InternalAppleStorage
663        );
664        assert_eq!(
665            disk.classification.destructive_default,
666            DestructiveDefault::Forbidden
667        );
668        assert_eq!(
669            disk.classification.confidence,
670            ClassificationConfidence::Strong
671        );
672        Ok(())
673    }
674
675    #[test]
676    fn attestation_selector_normalizes_dev_paths() {
677        assert_eq!(normalize_disk_selector("/dev/disk4"), "disk4");
678        assert_eq!(normalize_disk_selector("disk4"), "disk4");
679    }
680
681    #[test]
682    fn matches_macos_arm64_dev_starter() {
683        let inventory = HardwareInventory {
684            schema: HARDWARE_INVENTORY_SCHEMA_V1.to_string(),
685            platform: HardwarePlatform::Darwin,
686            arch: "aarch64".to_string(),
687            vendor: Some("Apple".to_string()),
688            model: Some("Mac17,7".to_string()),
689            model_name: Some("MacBook Pro".to_string()),
690            cpu: None,
691            memory: None,
692            disks: Vec::new(),
693            evidence: HardwareEvidence::default(),
694            warnings: Vec::new(),
695        };
696
697        let report = match_profiles(&inventory, Some("dev"));
698
699        assert_eq!(report.hardware_class, HardwareClassCandidate::MacosArm64);
700        assert_eq!(
701            report.recommendations[0].profile_ref,
702            "starter/macos-arm64-dev"
703        );
704        assert_eq!(
705            report.recommendations[0].confidence,
706            ClassificationConfidence::Strong
707        );
708    }
709}