diff --git a/app/src/source/firmware.rs b/app/src/source/firmware.rs index fdd7b560..44d01193 100644 --- a/app/src/source/firmware.rs +++ b/app/src/source/firmware.rs @@ -1,468 +1,473 @@ -//! Firmware table dumps for the Hex Viewer — ACPI and SMBIOS. -//! -//! # Why these tables, and not PCI config space -//! -//! The obvious hex-viewer target is PCI configuration space, but reading it -//! means port I/O through a kernel driver — the same WinRing0 path that -//! Windows' vulnerable-driver blocklist blocks (see the Driver Management tab). -//! ACPI and SMBIOS come out of `GetSystemFirmwareTable`, a plain kernel32 call -//! that needs **no driver and no elevation**, and yields multi-kilobyte dumps -//! with real structure in them. So the viewer is useful today, on a machine -//! with no working Ring-0 driver at all. -//! -//! On Linux the same tables are files under `/sys/firmware/`, though reading -//! them usually requires root. -//! -//! Collected on the slow lane: firmware tables are fixed at boot. - -use crate::inventory::{Inventory, InventorySource}; -#[allow(unused_imports)] -use crate::model::hexblob::{HexBlob, HexRegion, HexSource, RegionKind}; - -/// Enumerates the machine's firmware tables once per slow-lane pass. -pub struct FirmwareTables; - -impl InventorySource for FirmwareTables { - fn name(&self) -> &'static str { - "firmware tables" - } - - fn collect(&mut self) -> Inventory { - Inventory { hex: read_all(), ..Default::default() } - } -} - -fn read_all() -> Vec { - #[allow(unused_mut)] - let mut out = Vec::new(); - #[cfg(windows)] - { - out.extend(windows_impl::acpi_tables()); - out.extend(windows_impl::smbios()); - } - #[cfg(target_os = "linux")] - { - out.extend(linux_impl::acpi_tables()); - } - out -} - -// --------------------------------------------------------------------------- -// Structure annotation -// --------------------------------------------------------------------------- - -/// Length of the standard ACPI System Description Table header. -#[allow(dead_code)] -const ACPI_HEADER_LEN: usize = 36; - -/// Label the fields of the ACPI header (ACPI spec §21.2.1), so the viewer can -/// tint them and name whatever is under the cursor. -#[allow(dead_code)] -pub fn acpi_header_regions(bytes: &[u8]) -> Vec { - if bytes.len() < ACPI_HEADER_LEN { - return Vec::new(); - } - let r = |start: usize, len: usize, label: &str, kind: RegionKind| HexRegion { - start, - len, - label: label.to_string(), - kind, - }; - let mut regions = vec![ - r(0, ACPI_HEADER_LEN, "ACPI header", RegionKind::Payload), - r(0, 4, "Signature", RegionKind::Identity), - r(4, 4, "Length", RegionKind::Length), - r(8, 1, "Revision", RegionKind::Checksum), - r(9, 1, "Checksum", RegionKind::Checksum), - r(10, 6, "OEM ID", RegionKind::Identity), - r(16, 8, "OEM Table ID", RegionKind::Identity), - r(24, 4, "OEM Revision", RegionKind::Checksum), - r(28, 4, "Creator ID", RegionKind::Identity), - r(32, 4, "Creator Revision", RegionKind::Checksum), - ]; - if bytes.len() > ACPI_HEADER_LEN { - regions.push(r( - ACPI_HEADER_LEN, - bytes.len() - ACPI_HEADER_LEN, - "Table data", - RegionKind::Payload, - )); - } - regions -} - -/// The 8-byte `RawSMBIOSData` header Windows prepends to the DMI blob. -#[allow(dead_code)] -pub fn smbios_header_regions(bytes: &[u8]) -> Vec { - if bytes.len() < 8 { - return Vec::new(); - } - let r = |start: usize, len: usize, label: &str, kind: RegionKind| HexRegion { - start, - len, - label: label.to_string(), - kind, - }; - let mut regions = vec![ - r(0, 1, "Used 2.0 calling method", RegionKind::Checksum), - r(1, 1, "SMBIOS major version", RegionKind::Identity), - r(2, 1, "SMBIOS minor version", RegionKind::Identity), - r(3, 1, "DMI revision", RegionKind::Checksum), - r(4, 4, "Table length", RegionKind::Length), - ]; - if bytes.len() > 8 { - regions.push(r(8, bytes.len() - 8, "DMI structure table", RegionKind::Payload)); - } - regions -} - -/// ACPI signature/OEM fields are fixed-length ASCII; render them readably and -/// fall back to hex for the (malformed) non-printable case. -#[allow(dead_code)] -pub fn ascii_tag(bytes: &[u8]) -> String { - if bytes.iter().all(|&b| (0x20..0x7f).contains(&b)) { - String::from_utf8_lossy(bytes).trim_end().to_string() - } else { - bytes.iter().map(|b| format!("{b:02X}")).collect() - } -} - -// --------------------------------------------------------------------------- -// Windows -// --------------------------------------------------------------------------- - -#[cfg(windows)] -mod windows_impl { - use super::*; - - #[link(name = "kernel32")] - extern "system" { - fn EnumSystemFirmwareTables(provider: u32, buffer: *mut u8, size: u32) -> u32; - fn GetSystemFirmwareTable(provider: u32, table: u32, buffer: *mut u8, size: u32) -> u32; - } - - /// Provider signatures are the 4-character code packed **big-endian**: - /// 'ACPI' is 0x41435049, not the little-endian 0x49504341 you get from - /// reinterpreting the bytes. Getting this backwards is the classic way to - /// have every call return 0 with no error. - const fn provider(tag: &[u8; 4]) -> u32 { - u32::from_be_bytes(*tag) - } - - const ACPI: u32 = provider(b"ACPI"); - const RSMB: u32 = provider(b"RSMB"); - - /// Refuse absurd allocations if the API ever reports a nonsense size. - const MAX_TABLE_BYTES: u32 = 16 * 1024 * 1024; - - fn read_table(provider_sig: u32, table_id: u32) -> Option> { - unsafe { - // First call with a null buffer asks for the required size. - let size = GetSystemFirmwareTable(provider_sig, table_id, std::ptr::null_mut(), 0); - if size == 0 || size > MAX_TABLE_BYTES { - return None; - } - let mut buf = vec![0u8; size as usize]; - let written = GetSystemFirmwareTable(provider_sig, table_id, buf.as_mut_ptr(), size); - if written == 0 { - return None; - } - // A second call can legitimately return less than the probe did. - buf.truncate(written.min(size) as usize); - Some(buf) - } - } - - /// Every ACPI table the firmware published. - pub fn acpi_tables() -> Vec { - let ids = unsafe { - let size = EnumSystemFirmwareTables(ACPI, std::ptr::null_mut(), 0); - if size == 0 || size > MAX_TABLE_BYTES { - return Vec::new(); - } - let mut buf = vec![0u8; size as usize]; - let written = EnumSystemFirmwareTables(ACPI, buf.as_mut_ptr(), size); - if written == 0 { - return Vec::new(); - } - buf.truncate(written.min(size) as usize); - // The enumeration is an array of table IDs (4-byte signatures). - buf.chunks_exact(4) - .map(|c| u32::from_ne_bytes([c[0], c[1], c[2], c[3]])) - .collect::>() - }; - - // Firmware typically declares a dozen-plus SSDTs, but this API keys - // tables by *signature* — asking for 'SSDT' fifteen times returns the - // same bytes fifteen times. Read each signature once and report how - // many the firmware declared, rather than listing identical copies as - // if they were distinct tables. - let mut declared: std::collections::HashMap = Default::default(); - let mut order: Vec = Vec::new(); - for id in ids { - let seen_before = declared.entry(id).or_insert(0); - if *seen_before == 0 { - order.push(id); - } - *seen_before += 1; - } - - let mut out = Vec::new(); - for id in order { - let Some(bytes) = read_table(ACPI, id) else { continue }; - // The table's own header carries its signature — more trustworthy - // than re-deriving it from the enumerated DWORD's byte order. - let signature = if bytes.len() >= 4 { - ascii_tag(&bytes[..4]) - } else { - ascii_tag(&id.to_ne_bytes()) - }; - let regions = acpi_header_regions(&bytes); - out.push( - HexBlob::new( - HexSource::AcpiTable { - signature, - index: 0, - of: declared.get(&id).copied().unwrap_or(1), - }, - 0, - bytes, - ) - .with_regions(regions), - ); - } - out - } - - /// The raw SMBIOS/DMI table. - pub fn smbios() -> Vec { - let Some(bytes) = read_table(RSMB, 0) else { return Vec::new() }; - let version = if bytes.len() >= 3 { - format!("{}.{}", bytes[1], bytes[2]) - } else { - "?".into() - }; - let regions = smbios_header_regions(&bytes); - vec![HexBlob::new(HexSource::Smbios { version }, 0, bytes).with_regions(regions)] - } -} - -// --------------------------------------------------------------------------- -// Linux -// --------------------------------------------------------------------------- - -#[cfg(target_os = "linux")] -mod linux_impl { - use super::*; - - /// `/sys/firmware/acpi/tables/*` — one file per table. Readable only by - /// root on most distributions, so an empty result here is normal and not - /// worth surfacing as an error. - pub fn acpi_tables() -> Vec { - let Ok(dir) = std::fs::read_dir("/sys/firmware/acpi/tables") else { - return Vec::new(); - }; - let mut entries: Vec<_> = dir - .flatten() - .filter(|e| e.path().is_file()) - .map(|e| e.path()) - .collect(); - entries.sort(); - - // Unlike the Windows API, sysfs exposes each duplicate SSDT as its own - // file with its own contents, so index and count are both real here. - let mut tables: Vec<(String, Vec)> = Vec::new(); - for path in entries { - let Ok(bytes) = std::fs::read(&path) else { continue }; - if bytes.is_empty() { - continue; - } - // Prefer the in-table signature; the filename encodes the duplicate - // index (SSDT1, SSDT2, …) which we recompute anyway. - let signature = if bytes.len() >= 4 { - ascii_tag(&bytes[..4]) - } else { - path.file_name().unwrap_or_default().to_string_lossy().to_string() - }; - tables.push((signature, bytes)); - } - - let mut totals: std::collections::HashMap = Default::default(); - for (sig, _) in &tables { - *totals.entry(sig.clone()).or_insert(0) += 1; - } - - let mut seen: std::collections::HashMap = Default::default(); - let mut out = Vec::new(); - for (signature, bytes) in tables { - let index = seen.entry(signature.clone()).or_insert(0); - let of = totals.get(&signature).copied().unwrap_or(1); - let regions = acpi_header_regions(&bytes); - out.push( - HexBlob::new( - HexSource::AcpiTable { signature: signature.clone(), index: *index, of }, - 0, - bytes, - ) - .with_regions(regions), - ); - *index += 1; - } - out - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A minimal but well-formed ACPI table: header + 4 bytes of payload. - fn acpi_fixture() -> Vec { - let mut b = Vec::new(); - b.extend_from_slice(b"DSDT"); // signature - b.extend_from_slice(&40u32.to_le_bytes()); // length - b.push(2); // revision - b.push(0x5A); // checksum - b.extend_from_slice(b"ALASKA"); // OEM ID (6) - b.extend_from_slice(b"A M I \0"); // OEM table ID (8, incl. the NUL) - b.push(0); - b.extend_from_slice(&1u32.to_le_bytes()); // OEM revision - b.extend_from_slice(b"INTL"); // creator ID - b.extend_from_slice(&0x2020_0110u32.to_le_bytes()); // creator revision - b.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); // payload - assert_eq!(b.len(), 40); - b - } - - #[test] - fn acpi_header_fields_land_at_spec_offsets() { - let bytes = acpi_fixture(); - let blob = HexBlob::new( - HexSource::AcpiTable { signature: "DSDT".into(), index: 0, of: 1 }, - 0, - bytes.clone(), - ) - .with_regions(acpi_header_regions(&bytes)); - - assert_eq!(blob.region_at(0).unwrap().label, "Signature"); - assert_eq!(blob.region_at(4).unwrap().label, "Length"); - assert_eq!(blob.region_at(9).unwrap().label, "Checksum"); - assert_eq!(blob.region_at(10).unwrap().label, "OEM ID"); - assert_eq!(blob.region_at(32).unwrap().label, "Creator Revision"); - // Past the 36-byte header the payload region takes over. - assert_eq!(blob.region_at(36).unwrap().label, "Table data"); - } - - #[test] - fn a_truncated_table_gets_no_annotations_rather_than_wrong_ones() { - // Better to show plain hex than to label fields that aren't there. - assert!(acpi_header_regions(&[0u8; 10]).is_empty()); - assert!(smbios_header_regions(&[0u8; 4]).is_empty()); - // A header with no payload has no "Table data" region. - let header_only = acpi_header_regions(&[0u8; ACPI_HEADER_LEN]); - assert!(!header_only.iter().any(|r| r.label == "Table data")); - } - - #[test] - fn ascii_tags_fall_back_to_hex_when_not_printable() { - assert_eq!(ascii_tag(b"DSDT"), "DSDT"); - assert_eq!(ascii_tag(b"A M I "), "A M I"); - assert_eq!(ascii_tag(&[0x00, 0xFF]), "00FF"); - } - - #[cfg(windows)] - #[test] - fn provider_signature_is_packed_big_endian() { - // 'ACPI' == 0x41435049. The little-endian packing (0x49504341) is the - // classic mistake and makes every call silently return zero. - assert_eq!(u32::from_be_bytes(*b"ACPI"), 0x4143_5049); - assert_eq!(u32::from_be_bytes(*b"RSMB"), 0x5253_4D42); - } - - /// Exercises the real firmware on this machine when tests run on Windows. - /// Asserts only what must hold on any conforming system, so it stays green - /// on CI runners and inside VMs. - #[cfg(windows)] - #[test] - fn reads_real_acpi_tables() { - let blobs = windows_impl::acpi_tables(); - assert!(!blobs.is_empty(), "every x86 Windows machine publishes ACPI tables"); - - for b in &blobs { - // Each table's declared length must agree with what we read. - assert!(b.bytes.len() >= 4, "table too short to hold a signature"); - if b.bytes.len() >= 8 { - let declared = - u32::from_le_bytes([b.bytes[4], b.bytes[5], b.bytes[6], b.bytes[7]]) as usize; - assert_eq!( - declared, - b.bytes.len(), - "{}: header length disagrees with the bytes returned", - b.source.label() - ); - } - } - // The FADT ('FACP') is mandatory on every ACPI system and is one of the - // few this API reliably exposes. Note the DSDT is deliberately *not* - // asserted: Windows does not publish it through EnumSystemFirmwareTables - // even though it exists — verified on this machine, which lists 29 - // tables without one. - assert!( - blobs.iter().any(|b| matches!( - &b.source, - HexSource::AcpiTable { signature, .. } if signature == "FACP" - )), - "FADT is mandatory; got {:?}", - blobs.iter().map(|b| b.source.label()).collect::>() - ); - } - - /// The trap this API sets: ACPI tables are keyed by *signature*, so asking - /// for 'SSDT' fifteen times returns the same bytes fifteen times. Listing - /// those as fifteen tables would be a straight-up lie about the hardware. - #[cfg(windows)] - #[test] - fn duplicate_signatures_are_collapsed_and_counted() { - let blobs = windows_impl::acpi_tables(); - - let mut labels: Vec<_> = blobs.iter().map(|b| b.source.label()).collect(); - let before = labels.len(); - labels.sort(); - labels.dedup(); - assert_eq!(before, labels.len(), "every entry must be distinct: {labels:?}"); - - // No two entries may carry identical bytes. - for (i, a) in blobs.iter().enumerate() { - for b in &blobs[i + 1..] { - assert_ne!( - a.bytes, b.bytes, - "{} and {} are byte-identical", - a.source.label(), - b.source.label() - ); - } - } - - // Where firmware declared duplicates, the count is surfaced rather than - // silently dropped. - if let Some(dup) = blobs.iter().find( - |b| matches!(&b.source, HexSource::AcpiTable { of, .. } if *of > 1), - ) { - assert!(dup.source.label().contains(" of "), "{}", dup.source.label()); - } - } -} - -#[cfg(all(windows, test))] -mod probe { - #[test] - #[ignore = "diagnostic: prints what this machine's firmware actually publishes"] - fn list_tables() { - for b in super::windows_impl::acpi_tables() { - println!("{:<20} {:>8} bytes", b.source.label(), b.bytes.len()); - } - for b in super::windows_impl::smbios() { - println!("{:<20} {:>8} bytes", b.source.label(), b.bytes.len()); - } - } -} +//! Firmware table dumps for the Hex Viewer — ACPI and SMBIOS. +//! +//! # Why these tables, and not PCI config space +//! +//! The obvious hex-viewer target is PCI configuration space, but reading it +//! means port I/O through a kernel driver — the same WinRing0 path that +//! Windows' vulnerable-driver blocklist blocks (see the Driver Management tab). +//! ACPI and SMBIOS come out of `GetSystemFirmwareTable`, a plain kernel32 call +//! that needs **no driver and no elevation**, and yields multi-kilobyte dumps +//! with real structure in them. So the viewer is useful today, on a machine +//! with no working Ring-0 driver at all. +//! +//! On Linux the same tables are files under `/sys/firmware/`, though reading +//! them usually requires root. +//! +//! Collected on the slow lane: firmware tables are fixed at boot. + +use crate::inventory::{Inventory, InventorySource}; +#[allow(unused_imports)] +use crate::model::hexblob::{HexBlob, HexRegion, HexSource, RegionKind}; + +/// Enumerates the machine's firmware tables once per slow-lane pass. +pub struct FirmwareTables; + +impl InventorySource for FirmwareTables { + fn name(&self) -> &'static str { + "firmware tables" + } + + fn collect(&mut self) -> Inventory { + Inventory { hex: read_all(), ..Default::default() } + } +} + +fn read_all() -> Vec { + #[allow(unused_mut)] + let mut out = Vec::new(); + #[cfg(windows)] + { + out.extend(windows_impl::acpi_tables()); + out.extend(windows_impl::smbios()); + } + #[cfg(target_os = "linux")] + { + out.extend(linux_impl::acpi_tables()); + } + out +} + +// --------------------------------------------------------------------------- +// Structure annotation +// --------------------------------------------------------------------------- + +/// Length of the standard ACPI System Description Table header. +#[allow(dead_code)] +const ACPI_HEADER_LEN: usize = 36; + +/// Label the fields of the ACPI header (ACPI spec §21.2.1), so the viewer can +/// tint them and name whatever is under the cursor. +#[allow(dead_code)] +pub fn acpi_header_regions(bytes: &[u8]) -> Vec { + if bytes.len() < ACPI_HEADER_LEN { + return Vec::new(); + } + let r = |start: usize, len: usize, label: &str, kind: RegionKind| HexRegion { + start, + len, + label: label.to_string(), + kind, + }; + let mut regions = vec![ + r(0, ACPI_HEADER_LEN, "ACPI header", RegionKind::Payload), + r(0, 4, "Signature", RegionKind::Identity), + r(4, 4, "Length", RegionKind::Length), + r(8, 1, "Revision", RegionKind::Checksum), + r(9, 1, "Checksum", RegionKind::Checksum), + r(10, 6, "OEM ID", RegionKind::Identity), + r(16, 8, "OEM Table ID", RegionKind::Identity), + r(24, 4, "OEM Revision", RegionKind::Checksum), + r(28, 4, "Creator ID", RegionKind::Identity), + r(32, 4, "Creator Revision", RegionKind::Checksum), + ]; + if bytes.len() > ACPI_HEADER_LEN { + regions.push(r( + ACPI_HEADER_LEN, + bytes.len() - ACPI_HEADER_LEN, + "Table data", + RegionKind::Payload, + )); + } + regions +} + +/// The 8-byte `RawSMBIOSData` header Windows prepends to the DMI blob. +#[allow(dead_code)] +pub fn smbios_header_regions(bytes: &[u8]) -> Vec { + if bytes.len() < 8 { + return Vec::new(); + } + let r = |start: usize, len: usize, label: &str, kind: RegionKind| HexRegion { + start, + len, + label: label.to_string(), + kind, + }; + let mut regions = vec![ + r(0, 1, "Used 2.0 calling method", RegionKind::Checksum), + r(1, 1, "SMBIOS major version", RegionKind::Identity), + r(2, 1, "SMBIOS minor version", RegionKind::Identity), + r(3, 1, "DMI revision", RegionKind::Checksum), + r(4, 4, "Table length", RegionKind::Length), + ]; + if bytes.len() > 8 { + regions.push(r(8, bytes.len() - 8, "DMI structure table", RegionKind::Payload)); + } + regions +} + +/// ACPI signature/OEM fields are fixed-length ASCII; render them readably and +/// fall back to hex for the (malformed) non-printable case. +#[allow(dead_code)] +pub fn ascii_tag(bytes: &[u8]) -> String { + if bytes.iter().all(|&b| (0x20..0x7f).contains(&b)) { + String::from_utf8_lossy(bytes).trim_end().to_string() + } else { + bytes.iter().map(|b| format!("{b:02X}")).collect() + } +} + +// --------------------------------------------------------------------------- +// Windows +// --------------------------------------------------------------------------- + +#[cfg(windows)] +mod windows_impl { + use super::*; + + #[link(name = "kernel32")] + extern "system" { + fn EnumSystemFirmwareTables(provider: u32, buffer: *mut u8, size: u32) -> u32; + fn GetSystemFirmwareTable(provider: u32, table: u32, buffer: *mut u8, size: u32) -> u32; + } + + /// Provider signatures are the 4-character code packed **big-endian**: + /// 'ACPI' is 0x41435049, not the little-endian 0x49504341 you get from + /// reinterpreting the bytes. Getting this backwards is the classic way to + /// have every call return 0 with no error. + const fn provider(tag: &[u8; 4]) -> u32 { + u32::from_be_bytes(*tag) + } + + const ACPI: u32 = provider(b"ACPI"); + const RSMB: u32 = provider(b"RSMB"); + + /// Refuse absurd allocations if the API ever reports a nonsense size. + const MAX_TABLE_BYTES: u32 = 16 * 1024 * 1024; + + fn read_table(provider_sig: u32, table_id: u32) -> Option> { + unsafe { + // First call with a null buffer asks for the required size. + let size = GetSystemFirmwareTable(provider_sig, table_id, std::ptr::null_mut(), 0); + if size == 0 || size > MAX_TABLE_BYTES { + return None; + } + let mut buf = vec![0u8; size as usize]; + let written = GetSystemFirmwareTable(provider_sig, table_id, buf.as_mut_ptr(), size); + if written == 0 { + return None; + } + // A second call can legitimately return less than the probe did. + buf.truncate(written.min(size) as usize); + Some(buf) + } + } + + /// Every ACPI table the firmware published. + pub fn acpi_tables() -> Vec { + let ids = unsafe { + let size = EnumSystemFirmwareTables(ACPI, std::ptr::null_mut(), 0); + if size == 0 || size > MAX_TABLE_BYTES { + return Vec::new(); + } + let mut buf = vec![0u8; size as usize]; + let written = EnumSystemFirmwareTables(ACPI, buf.as_mut_ptr(), size); + if written == 0 { + return Vec::new(); + } + buf.truncate(written.min(size) as usize); + // The enumeration is an array of table IDs (4-byte signatures). + // `as_chunks` rather than `chunks_exact`: it yields `&[u8; 4]`, + // so the signature converts without re-indexing, and clippy's + // `chunks_exact_to_as_chunks` (new in Rust 1.98) asks for it. + buf.as_chunks::<4>() + .0 + .iter() + .map(|c| u32::from_ne_bytes(*c)) + .collect::>() + }; + + // Firmware typically declares a dozen-plus SSDTs, but this API keys + // tables by *signature* — asking for 'SSDT' fifteen times returns the + // same bytes fifteen times. Read each signature once and report how + // many the firmware declared, rather than listing identical copies as + // if they were distinct tables. + let mut declared: std::collections::HashMap = Default::default(); + let mut order: Vec = Vec::new(); + for id in ids { + let seen_before = declared.entry(id).or_insert(0); + if *seen_before == 0 { + order.push(id); + } + *seen_before += 1; + } + + let mut out = Vec::new(); + for id in order { + let Some(bytes) = read_table(ACPI, id) else { continue }; + // The table's own header carries its signature — more trustworthy + // than re-deriving it from the enumerated DWORD's byte order. + let signature = if bytes.len() >= 4 { + ascii_tag(&bytes[..4]) + } else { + ascii_tag(&id.to_ne_bytes()) + }; + let regions = acpi_header_regions(&bytes); + out.push( + HexBlob::new( + HexSource::AcpiTable { + signature, + index: 0, + of: declared.get(&id).copied().unwrap_or(1), + }, + 0, + bytes, + ) + .with_regions(regions), + ); + } + out + } + + /// The raw SMBIOS/DMI table. + pub fn smbios() -> Vec { + let Some(bytes) = read_table(RSMB, 0) else { return Vec::new() }; + let version = if bytes.len() >= 3 { + format!("{}.{}", bytes[1], bytes[2]) + } else { + "?".into() + }; + let regions = smbios_header_regions(&bytes); + vec![HexBlob::new(HexSource::Smbios { version }, 0, bytes).with_regions(regions)] + } +} + +// --------------------------------------------------------------------------- +// Linux +// --------------------------------------------------------------------------- + +#[cfg(target_os = "linux")] +mod linux_impl { + use super::*; + + /// `/sys/firmware/acpi/tables/*` — one file per table. Readable only by + /// root on most distributions, so an empty result here is normal and not + /// worth surfacing as an error. + pub fn acpi_tables() -> Vec { + let Ok(dir) = std::fs::read_dir("/sys/firmware/acpi/tables") else { + return Vec::new(); + }; + let mut entries: Vec<_> = dir + .flatten() + .filter(|e| e.path().is_file()) + .map(|e| e.path()) + .collect(); + entries.sort(); + + // Unlike the Windows API, sysfs exposes each duplicate SSDT as its own + // file with its own contents, so index and count are both real here. + let mut tables: Vec<(String, Vec)> = Vec::new(); + for path in entries { + let Ok(bytes) = std::fs::read(&path) else { continue }; + if bytes.is_empty() { + continue; + } + // Prefer the in-table signature; the filename encodes the duplicate + // index (SSDT1, SSDT2, …) which we recompute anyway. + let signature = if bytes.len() >= 4 { + ascii_tag(&bytes[..4]) + } else { + path.file_name().unwrap_or_default().to_string_lossy().to_string() + }; + tables.push((signature, bytes)); + } + + let mut totals: std::collections::HashMap = Default::default(); + for (sig, _) in &tables { + *totals.entry(sig.clone()).or_insert(0) += 1; + } + + let mut seen: std::collections::HashMap = Default::default(); + let mut out = Vec::new(); + for (signature, bytes) in tables { + let index = seen.entry(signature.clone()).or_insert(0); + let of = totals.get(&signature).copied().unwrap_or(1); + let regions = acpi_header_regions(&bytes); + out.push( + HexBlob::new( + HexSource::AcpiTable { signature: signature.clone(), index: *index, of }, + 0, + bytes, + ) + .with_regions(regions), + ); + *index += 1; + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal but well-formed ACPI table: header + 4 bytes of payload. + fn acpi_fixture() -> Vec { + let mut b = Vec::new(); + b.extend_from_slice(b"DSDT"); // signature + b.extend_from_slice(&40u32.to_le_bytes()); // length + b.push(2); // revision + b.push(0x5A); // checksum + b.extend_from_slice(b"ALASKA"); // OEM ID (6) + b.extend_from_slice(b"A M I \0"); // OEM table ID (8, incl. the NUL) + b.push(0); + b.extend_from_slice(&1u32.to_le_bytes()); // OEM revision + b.extend_from_slice(b"INTL"); // creator ID + b.extend_from_slice(&0x2020_0110u32.to_le_bytes()); // creator revision + b.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); // payload + assert_eq!(b.len(), 40); + b + } + + #[test] + fn acpi_header_fields_land_at_spec_offsets() { + let bytes = acpi_fixture(); + let blob = HexBlob::new( + HexSource::AcpiTable { signature: "DSDT".into(), index: 0, of: 1 }, + 0, + bytes.clone(), + ) + .with_regions(acpi_header_regions(&bytes)); + + assert_eq!(blob.region_at(0).unwrap().label, "Signature"); + assert_eq!(blob.region_at(4).unwrap().label, "Length"); + assert_eq!(blob.region_at(9).unwrap().label, "Checksum"); + assert_eq!(blob.region_at(10).unwrap().label, "OEM ID"); + assert_eq!(blob.region_at(32).unwrap().label, "Creator Revision"); + // Past the 36-byte header the payload region takes over. + assert_eq!(blob.region_at(36).unwrap().label, "Table data"); + } + + #[test] + fn a_truncated_table_gets_no_annotations_rather_than_wrong_ones() { + // Better to show plain hex than to label fields that aren't there. + assert!(acpi_header_regions(&[0u8; 10]).is_empty()); + assert!(smbios_header_regions(&[0u8; 4]).is_empty()); + // A header with no payload has no "Table data" region. + let header_only = acpi_header_regions(&[0u8; ACPI_HEADER_LEN]); + assert!(!header_only.iter().any(|r| r.label == "Table data")); + } + + #[test] + fn ascii_tags_fall_back_to_hex_when_not_printable() { + assert_eq!(ascii_tag(b"DSDT"), "DSDT"); + assert_eq!(ascii_tag(b"A M I "), "A M I"); + assert_eq!(ascii_tag(&[0x00, 0xFF]), "00FF"); + } + + #[cfg(windows)] + #[test] + fn provider_signature_is_packed_big_endian() { + // 'ACPI' == 0x41435049. The little-endian packing (0x49504341) is the + // classic mistake and makes every call silently return zero. + assert_eq!(u32::from_be_bytes(*b"ACPI"), 0x4143_5049); + assert_eq!(u32::from_be_bytes(*b"RSMB"), 0x5253_4D42); + } + + /// Exercises the real firmware on this machine when tests run on Windows. + /// Asserts only what must hold on any conforming system, so it stays green + /// on CI runners and inside VMs. + #[cfg(windows)] + #[test] + fn reads_real_acpi_tables() { + let blobs = windows_impl::acpi_tables(); + assert!(!blobs.is_empty(), "every x86 Windows machine publishes ACPI tables"); + + for b in &blobs { + // Each table's declared length must agree with what we read. + assert!(b.bytes.len() >= 4, "table too short to hold a signature"); + if b.bytes.len() >= 8 { + let declared = + u32::from_le_bytes([b.bytes[4], b.bytes[5], b.bytes[6], b.bytes[7]]) as usize; + assert_eq!( + declared, + b.bytes.len(), + "{}: header length disagrees with the bytes returned", + b.source.label() + ); + } + } + // The FADT ('FACP') is mandatory on every ACPI system and is one of the + // few this API reliably exposes. Note the DSDT is deliberately *not* + // asserted: Windows does not publish it through EnumSystemFirmwareTables + // even though it exists — verified on this machine, which lists 29 + // tables without one. + assert!( + blobs.iter().any(|b| matches!( + &b.source, + HexSource::AcpiTable { signature, .. } if signature == "FACP" + )), + "FADT is mandatory; got {:?}", + blobs.iter().map(|b| b.source.label()).collect::>() + ); + } + + /// The trap this API sets: ACPI tables are keyed by *signature*, so asking + /// for 'SSDT' fifteen times returns the same bytes fifteen times. Listing + /// those as fifteen tables would be a straight-up lie about the hardware. + #[cfg(windows)] + #[test] + fn duplicate_signatures_are_collapsed_and_counted() { + let blobs = windows_impl::acpi_tables(); + + let mut labels: Vec<_> = blobs.iter().map(|b| b.source.label()).collect(); + let before = labels.len(); + labels.sort(); + labels.dedup(); + assert_eq!(before, labels.len(), "every entry must be distinct: {labels:?}"); + + // No two entries may carry identical bytes. + for (i, a) in blobs.iter().enumerate() { + for b in &blobs[i + 1..] { + assert_ne!( + a.bytes, b.bytes, + "{} and {} are byte-identical", + a.source.label(), + b.source.label() + ); + } + } + + // Where firmware declared duplicates, the count is surfaced rather than + // silently dropped. + if let Some(dup) = blobs.iter().find( + |b| matches!(&b.source, HexSource::AcpiTable { of, .. } if *of > 1), + ) { + assert!(dup.source.label().contains(" of "), "{}", dup.source.label()); + } + } +} + +#[cfg(all(windows, test))] +mod probe { + #[test] + #[ignore = "diagnostic: prints what this machine's firmware actually publishes"] + fn list_tables() { + for b in super::windows_impl::acpi_tables() { + println!("{:<20} {:>8} bytes", b.source.label(), b.bytes.len()); + } + for b in super::windows_impl::smbios() { + println!("{:<20} {:>8} bytes", b.source.label(), b.bytes.len()); + } + } +} diff --git a/app/src/source/macos/dvfs.rs b/app/src/source/macos/dvfs.rs index ba48ed8f..b35d51d0 100644 --- a/app/src/source/macos/dvfs.rs +++ b/app/src/source/macos/dvfs.rs @@ -1,172 +1,177 @@ -//! DVFS (frequency/voltage) tables from the SoC power manager. -//! -//! Apple Silicon does not expose a "current MHz" register. Frequency has to be -//! reconstructed: the `pmgr` device-tree node lists the discrete performance -//! states each block can run at, and IOReport reports how long the block spent -//! in each one (see [`super::ioreport`]). Multiplying the two gives an -//! effective clock — the same thing `powermetrics` prints. -//! -//! The tables are packed arrays of `(frequency, voltage)` `u32` pairs. Units -//! are **not** consistent between blocks on the same machine: on this M5 the -//! CPU tables are in kHz (max 4,464,000 = 4464 MHz) while the GPU table is in -//! Hz (max 1,578,000,000 = 1578 MHz), so the scale is detected from the -//! magnitude rather than assumed. - -use super::iokit; - -/// Device-tree path to the power manager. -const PMGR_PATH: &str = "IODeviceTree:/arm-io/pmgr"; - -/// Which block's performance-state table to read. -/// -/// The `-sram` variants are used for the CPU clusters because the plain -/// `voltage-states1`/`5` entries describe a different rail; the SRAM tables are -/// the ones whose frequencies match the cores. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Block { - /// Efficiency cluster. - Ecpu, - /// Performance cluster. - Pcpu, - Gpu, -} - -impl Block { - fn property(self) -> &'static str { - match self { - Block::Ecpu => "voltage-states1-sram", - Block::Pcpu => "voltage-states5-sram", - Block::Gpu => "voltage-states9", - } - } -} - -/// One DVFS performance state. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct State { - pub mhz: f32, - /// Rail voltage for this state, in volts. The tables pair every frequency - /// with the voltage needed to sustain it, which is where the CPU "VID" - /// reading comes from — there is no separate voltage sensor on Apple - /// Silicon. - pub volts: f32, -} - -/// Available frequencies for a block, in MHz, in performance-state order. -/// -/// Empty when the node or property is missing — every caller treats that as -/// "no frequency sensors for this block" rather than an error. -pub fn frequencies_mhz(block: Block) -> Vec { - states(block).into_iter().map(|s| s.mhz).collect() -} - -/// Full performance-state table (frequency + voltage). -pub fn states(block: Block) -> Vec { - let Some(entry) = iokit::entry_from_path(PMGR_PATH) else { - return Vec::new(); - }; - let Some(props) = iokit::properties(entry.0) else { - return Vec::new(); - }; - let Some(bytes) = iokit::dict_data(&props, block.property()) else { - return Vec::new(); - }; - parse_states(&bytes) -} - -/// Decode packed `(freq, voltage)` `u32` pairs. -fn parse_states(bytes: &[u8]) -> Vec { - let pairs: Vec<(u32, u32)> = bytes - .chunks_exact(8) - .map(|c| { - ( - u32::from_le_bytes([c[0], c[1], c[2], c[3]]), - u32::from_le_bytes([c[4], c[5], c[6], c[7]]), - ) - }) - .collect(); - if pairs.is_empty() { - return Vec::new(); - } - - // Detect the unit from the largest entry. No Apple SoC runs at 100 GHz, and - // none has a 100 MHz *maximum*, so this threshold separates Hz from kHz - // without needing a per-block table that would rot on the next chip. - let max = pairs.iter().map(|(f, _)| *f).max().unwrap_or(0) as f64; - let to_mhz: f64 = if max >= 100_000_000.0 { 1.0e6 } else { 1.0e3 }; - - pairs - .iter() - .map(|(freq, mv)| State { - mhz: (*freq as f64 / to_mhz) as f32, - // Voltages are millivolts (790 => 0.790 V). - volts: *mv as f32 / 1000.0, - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cpu_tables_are_plausible_and_ascending() { - for block in [Block::Ecpu, Block::Pcpu] { - let states = frequencies_mhz(block); - if states.is_empty() { - crate::source::macos::absent(&format!("{block:?} DVFS table")); - continue; - } - // Every Apple core sits between a few hundred MHz and ~6 GHz. A - // unit-scale mistake lands far outside this on either side. - for mhz in &states { - assert!( - (100.0..=6000.0).contains(mhz), - "{block:?} state {mhz} MHz implies the kHz/Hz scale was misread" - ); - } - let top = states.last().copied().unwrap(); - assert!(top >= 2000.0, "{block:?} top state {top} MHz is too low"); - } - } - - /// The GPU table is stored in Hz where the CPU tables are in kHz — this is - /// the case the magnitude heuristic exists for. - #[test] - fn gpu_table_uses_a_different_unit_but_still_decodes_to_mhz() { - let states = frequencies_mhz(Block::Gpu); - if states.is_empty() { - return crate::source::macos::absent("GPU DVFS table"); - } - let top = states.last().copied().unwrap(); - assert!( - (300.0..=4000.0).contains(&top), - "GPU top state {top} MHz implies the Hz/kHz scale was misread" - ); - } - - #[test] - fn scale_detection_handles_both_units() { - // kHz-encoded: 972 MHz and 4464 MHz. - let khz = [972_000u32, 790, 4_464_000, 980] - .iter() - .flat_map(|v| v.to_le_bytes()) - .collect::>(); - assert_eq!(parse_states(&khz).iter().map(|s| s.mhz).collect::>(), vec![972.0, 4464.0]); - assert_eq!(parse_states(&khz)[0].volts, 0.790); - - // Hz-encoded: 338 MHz and 1578 MHz. - let hz = [338_000_000u32, 500, 1_578_000_000, 900] - .iter() - .flat_map(|v| v.to_le_bytes()) - .collect::>(); - assert_eq!(parse_states(&hz).iter().map(|s| s.mhz).collect::>(), vec![338.0, 1578.0]); - } - - #[test] - fn truncated_table_does_not_panic() { - assert!(parse_states(&[]).is_empty()); - // Fewer than one full pair — chunks_exact drops the remainder. - assert!(parse_states(&[1, 2, 3]).is_empty()); - } -} +//! DVFS (frequency/voltage) tables from the SoC power manager. +//! +//! Apple Silicon does not expose a "current MHz" register. Frequency has to be +//! reconstructed: the `pmgr` device-tree node lists the discrete performance +//! states each block can run at, and IOReport reports how long the block spent +//! in each one (see [`super::ioreport`]). Multiplying the two gives an +//! effective clock — the same thing `powermetrics` prints. +//! +//! The tables are packed arrays of `(frequency, voltage)` `u32` pairs. Units +//! are **not** consistent between blocks on the same machine: on this M5 the +//! CPU tables are in kHz (max 4,464,000 = 4464 MHz) while the GPU table is in +//! Hz (max 1,578,000,000 = 1578 MHz), so the scale is detected from the +//! magnitude rather than assumed. + +use super::iokit; + +/// Device-tree path to the power manager. +const PMGR_PATH: &str = "IODeviceTree:/arm-io/pmgr"; + +/// Which block's performance-state table to read. +/// +/// The `-sram` variants are used for the CPU clusters because the plain +/// `voltage-states1`/`5` entries describe a different rail; the SRAM tables are +/// the ones whose frequencies match the cores. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Block { + /// Efficiency cluster. + Ecpu, + /// Performance cluster. + Pcpu, + Gpu, +} + +impl Block { + fn property(self) -> &'static str { + match self { + Block::Ecpu => "voltage-states1-sram", + Block::Pcpu => "voltage-states5-sram", + Block::Gpu => "voltage-states9", + } + } +} + +/// One DVFS performance state. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct State { + pub mhz: f32, + /// Rail voltage for this state, in volts. The tables pair every frequency + /// with the voltage needed to sustain it, which is where the CPU "VID" + /// reading comes from — there is no separate voltage sensor on Apple + /// Silicon. + pub volts: f32, +} + +/// Available frequencies for a block, in MHz, in performance-state order. +/// +/// Empty when the node or property is missing — every caller treats that as +/// "no frequency sensors for this block" rather than an error. +pub fn frequencies_mhz(block: Block) -> Vec { + states(block).into_iter().map(|s| s.mhz).collect() +} + +/// Full performance-state table (frequency + voltage). +pub fn states(block: Block) -> Vec { + let Some(entry) = iokit::entry_from_path(PMGR_PATH) else { + return Vec::new(); + }; + let Some(props) = iokit::properties(entry.0) else { + return Vec::new(); + }; + let Some(bytes) = iokit::dict_data(&props, block.property()) else { + return Vec::new(); + }; + parse_states(&bytes) +} + +/// Decode packed `(freq, voltage)` `u32` pairs. +fn parse_states(bytes: &[u8]) -> Vec { + // `as_chunks` rather than `chunks_exact` — same semantics (the trailing + // partial pair is dropped either way), but it yields a fixed-size array and + // satisfies clippy's `chunks_exact_to_as_chunks`, new in Rust 1.98. + let pairs: Vec<(u32, u32)> = bytes + .as_chunks::<8>() + .0 + .iter() + .map(|c| { + ( + u32::from_le_bytes([c[0], c[1], c[2], c[3]]), + u32::from_le_bytes([c[4], c[5], c[6], c[7]]), + ) + }) + .collect(); + if pairs.is_empty() { + return Vec::new(); + } + + // Detect the unit from the largest entry. No Apple SoC runs at 100 GHz, and + // none has a 100 MHz *maximum*, so this threshold separates Hz from kHz + // without needing a per-block table that would rot on the next chip. + let max = pairs.iter().map(|(f, _)| *f).max().unwrap_or(0) as f64; + let to_mhz: f64 = if max >= 100_000_000.0 { 1.0e6 } else { 1.0e3 }; + + pairs + .iter() + .map(|(freq, mv)| State { + mhz: (*freq as f64 / to_mhz) as f32, + // Voltages are millivolts (790 => 0.790 V). + volts: *mv as f32 / 1000.0, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cpu_tables_are_plausible_and_ascending() { + for block in [Block::Ecpu, Block::Pcpu] { + let states = frequencies_mhz(block); + if states.is_empty() { + crate::source::macos::absent(&format!("{block:?} DVFS table")); + continue; + } + // Every Apple core sits between a few hundred MHz and ~6 GHz. A + // unit-scale mistake lands far outside this on either side. + for mhz in &states { + assert!( + (100.0..=6000.0).contains(mhz), + "{block:?} state {mhz} MHz implies the kHz/Hz scale was misread" + ); + } + let top = states.last().copied().unwrap(); + assert!(top >= 2000.0, "{block:?} top state {top} MHz is too low"); + } + } + + /// The GPU table is stored in Hz where the CPU tables are in kHz — this is + /// the case the magnitude heuristic exists for. + #[test] + fn gpu_table_uses_a_different_unit_but_still_decodes_to_mhz() { + let states = frequencies_mhz(Block::Gpu); + if states.is_empty() { + return crate::source::macos::absent("GPU DVFS table"); + } + let top = states.last().copied().unwrap(); + assert!( + (300.0..=4000.0).contains(&top), + "GPU top state {top} MHz implies the Hz/kHz scale was misread" + ); + } + + #[test] + fn scale_detection_handles_both_units() { + // kHz-encoded: 972 MHz and 4464 MHz. + let khz = [972_000u32, 790, 4_464_000, 980] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(); + assert_eq!(parse_states(&khz).iter().map(|s| s.mhz).collect::>(), vec![972.0, 4464.0]); + assert_eq!(parse_states(&khz)[0].volts, 0.790); + + // Hz-encoded: 338 MHz and 1578 MHz. + let hz = [338_000_000u32, 500, 1_578_000_000, 900] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(); + assert_eq!(parse_states(&hz).iter().map(|s| s.mhz).collect::>(), vec![338.0, 1578.0]); + } + + #[test] + fn truncated_table_does_not_panic() { + assert!(parse_states(&[]).is_empty()); + // Fewer than one full pair — chunks_exact drops the remainder. + assert!(parse_states(&[1, 2, 3]).is_empty()); + } +}