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}