diff --git a/local/recipes/system/redbear-hwutils/source/src/bin/redbear-phase5-network-check.rs b/local/recipes/system/redbear-hwutils/source/src/bin/redbear-phase5-network-check.rs index c6e94df1..85911c5d 100644 --- a/local/recipes/system/redbear-hwutils/source/src/bin/redbear-phase5-network-check.rs +++ b/local/recipes/system/redbear-hwutils/source/src/bin/redbear-phase5-network-check.rs @@ -1,5 +1,9 @@ -use std::path::Path; -use std::process::{self, Command}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, + process::{self, Command}, +}; use redbear_hwutils::parse_args; use serde_json::Value; @@ -7,6 +11,46 @@ use serde_json::Value; const PROGRAM: &str = "redbear-phase5-network-check"; const USAGE: &str = "Usage: redbear-phase5-network-check\n\nShow the installed Phase 5 networking/session plumbing surface inside the guest."; +const DBUS_SEND: &str = "dbus-send"; +const POWER_ROOT: &str = "/scheme/acpi/power"; +const UPOWER_DESTINATION: &str = "org.freedesktop.UPower"; +const UPOWER_PATH: &str = "/org/freedesktop/UPower"; +const UDISKS_DESTINATION: &str = "org.freedesktop.UDisks2"; +const UDISKS_ROOT_PATH: &str = "/org/freedesktop/UDisks2"; +const UDISKS_MANAGER_PATH: &str = "/org/freedesktop/UDisks2/Manager"; +const UDISKS_BLOCK_PREFIX: &str = "/org/freedesktop/UDisks2/block_devices/"; +const UDISKS_DRIVE_PREFIX: &str = "/org/freedesktop/UDisks2/drives/"; + +#[derive(Debug, Default)] +struct PowerRuntime { + adapter_ids: Vec, + battery_ids: Vec, + native_paths_by_object: BTreeMap, +} + +#[derive(Debug, Default)] +struct DiskRuntime { + block_paths: BTreeSet, + drive_paths: BTreeSet, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct RootKey { + disk_number: u32, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct PartitionKey { + disk_number: u32, + partition_number: u32, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EntryKind { + Root(RootKey), + Partition(PartitionKey), +} + fn require_path(path: &str) -> Result<(), String> { if Path::new(path).exists() { println!("{path}"); @@ -31,6 +75,459 @@ fn optional_require_path(path: &str, label: &str) { } } +fn require_one_path<'a>(paths: &'a [&'a str], label: &str) -> Result<&'a str, String> { + for path in paths { + if Path::new(path).exists() { + println!("{label}={path}"); + return Ok(*path); + } + } + + Err(format!("missing any of: {}", paths.join(", "))) +} + +fn list_dir_names(path: impl AsRef) -> Vec { + let mut names = match fs::read_dir(path) { + Ok(entries) => entries + .filter_map(|entry| entry.ok()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect::>(), + Err(_) => Vec::new(), + }; + names.sort(); + names +} + +fn read_trimmed(path: impl AsRef) -> Option { + let value = fs::read_to_string(path).ok()?; + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn run_command(program: &str, args: &[&str], label: &str) -> Result { + let output = Command::new(program) + .args(args) + .output() + .map_err(|err| format!("failed to run {label}: {err}"))?; + + if !output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = if !stderr.trim().is_empty() { + stderr.trim().to_string() + } else if !stdout.trim().is_empty() { + stdout.trim().to_string() + } else { + String::from("no output") + }; + return Err(format!( + "{label} exited with status {}: {detail}", + output.status + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn quoted_values_with_prefix(output: &str, prefix: &str) -> BTreeSet { + let mut values = BTreeSet::new(); + + for line in output.lines() { + let mut remainder = line; + while let Some(start) = remainder.find('"') { + remainder = &remainder[start + 1..]; + let Some(end) = remainder.find('"') else { + break; + }; + let candidate = &remainder[..end]; + if candidate.starts_with(prefix) { + values.insert(candidate.to_string()); + } + remainder = &remainder[end + 1..]; + } + } + + values +} + +fn quoted_value(line: &str) -> Option<&str> { + let start = line.find('"')?; + let remainder = &line[start + 1..]; + let end = remainder.find('"')?; + Some(&remainder[..end]) +} + +fn managed_object_keys_with_prefix(output: &str, prefix: &str) -> BTreeSet { + let lines = output.lines().collect::>(); + let mut values = BTreeSet::new(); + + for window in lines.windows(2) { + let current = window[0].trim(); + let next = window[1].trim(); + if current != "dict entry(" || !next.starts_with("object path ") { + continue; + } + + let Some(candidate) = quoted_value(next) else { + continue; + }; + if candidate.starts_with(prefix) { + values.insert(candidate.to_string()); + } + } + + values +} + +fn summarize_set(values: &BTreeSet) -> String { + if values.is_empty() { + String::from("none") + } else { + values.iter().cloned().collect::>().join(", ") + } +} + +fn note_bus_name_registered(list_names_output: &str, bus_name: &str, label: &str) { + if list_names_output.contains(bus_name) { + println!("{label}=present"); + } else { + println!("{label}=activated_lazily"); + } +} + +fn contains_phase5_wifi_success(text: &str) -> bool { + text.contains("PASS: bounded Intel Wi-Fi runtime path exercised inside target runtime") + || text.contains("PASS: bounded Intel Wi-Fi runtime path exercised on target") +} + +fn adapter_object_path(id: &str) -> String { + format!("/org/freedesktop/UPower/devices/line_power_{id}") +} + +fn battery_object_path(id: &str) -> String { + format!("/org/freedesktop/UPower/devices/battery_{id}") +} + +impl PowerRuntime { + fn discover() -> Self { + let adapter_ids = list_dir_names(PathBuf::from(POWER_ROOT).join("adapters")); + let battery_ids = list_dir_names(PathBuf::from(POWER_ROOT).join("batteries")); + let mut native_paths_by_object = BTreeMap::new(); + + for adapter_id in &adapter_ids { + if let Some(native_path) = read_trimmed( + PathBuf::from(POWER_ROOT) + .join("adapters") + .join(adapter_id) + .join("path"), + ) { + native_paths_by_object.insert(adapter_object_path(adapter_id), native_path); + } + } + + for battery_id in &battery_ids { + if let Some(native_path) = read_trimmed( + PathBuf::from(POWER_ROOT) + .join("batteries") + .join(battery_id) + .join("path"), + ) { + native_paths_by_object.insert(battery_object_path(battery_id), native_path); + } + } + + Self { + adapter_ids, + battery_ids, + native_paths_by_object, + } + } + + fn expected_device_paths(&self) -> BTreeSet { + let mut paths = BTreeSet::new(); + for adapter_id in &self.adapter_ids { + paths.insert(adapter_object_path(adapter_id)); + } + for battery_id in &self.battery_ids { + paths.insert(battery_object_path(battery_id)); + } + paths + } +} + +fn validate_upower(list_names_output: &str) -> Result<(), String> { + let runtime = PowerRuntime::discover(); + let expected_device_paths = runtime.expected_device_paths(); + println!("UPOWER_RUNTIME_ADAPTERS={}", runtime.adapter_ids.len()); + println!("UPOWER_RUNTIME_BATTERIES={}", runtime.battery_ids.len()); + + let enumerate_output = run_command( + DBUS_SEND, + &[ + "--system", + "--dest=org.freedesktop.UPower", + "--type=method_call", + "--print-reply", + UPOWER_PATH, + "org.freedesktop.UPower.EnumerateDevices", + ], + "dbus-send UPower EnumerateDevices", + )?; + let enumerated_device_paths = + quoted_values_with_prefix(&enumerate_output, "/org/freedesktop/UPower/devices/"); + println!( + "UPOWER_ENUMERATED_DEVICES={}", + enumerated_device_paths.len() + ); + note_bus_name_registered(list_names_output, UPOWER_DESTINATION, "UPOWER_BUS_NAME"); + + let missing_device_paths = expected_device_paths + .difference(&enumerated_device_paths) + .cloned() + .collect::>(); + if !missing_device_paths.is_empty() { + return Err(format!( + "UPower did not enumerate runtime-backed devices: {}", + summarize_set(&missing_device_paths) + )); + } + + let unexpected_device_paths = enumerated_device_paths + .difference(&expected_device_paths) + .cloned() + .collect::>(); + if !unexpected_device_paths.is_empty() { + return Err(format!( + "UPower enumerated devices not backed by /scheme/acpi/power: {}", + summarize_set(&unexpected_device_paths) + )); + } + + for (object_path, expected_native_path) in &runtime.native_paths_by_object { + let native_path_output = run_command( + DBUS_SEND, + &[ + "--system", + "--dest=org.freedesktop.UPower", + "--type=method_call", + "--print-reply", + object_path.as_str(), + "org.freedesktop.DBus.Properties.Get", + "string:org.freedesktop.UPower.Device", + "string:NativePath", + ], + "dbus-send UPower Device.NativePath", + )?; + let reported_paths = quoted_values_with_prefix(&native_path_output, "/"); + let reported_native_path = reported_paths.iter().next().ok_or_else(|| { + format!("UPower device {object_path} did not return a NativePath property value") + })?; + + if reported_native_path != expected_native_path { + return Err(format!( + "UPower device {object_path} reported NativePath {reported_native_path}, expected {expected_native_path}" + )); + } + } + + println!("UPOWER_NATIVE_PATHS=validated"); + Ok(()) +} + +impl DiskRuntime { + fn discover() -> Self { + let mut block_paths = BTreeSet::new(); + let mut drive_paths = BTreeSet::new(); + + for scheme_name in list_dir_names("/scheme") + .into_iter() + .filter(|name| name.starts_with("disk.")) + { + let scheme_path = PathBuf::from("/scheme").join(&scheme_name); + + for entry_name in list_dir_names(&scheme_path) { + match parse_disk_entry_name(&entry_name) { + Some(EntryKind::Root(_)) => { + drive_paths.insert(drive_object_path(&scheme_name, &entry_name)); + block_paths.insert(block_object_path(&scheme_name, &entry_name)); + } + Some(EntryKind::Partition(_)) => { + block_paths.insert(block_object_path(&scheme_name, &entry_name)); + } + None => {} + } + } + } + + Self { + block_paths, + drive_paths, + } + } +} + +fn parse_disk_entry_name(entry_name: &str) -> Option { + if let Some(position) = entry_name.find('p') { + let disk_number = entry_name[..position].parse().ok()?; + let partition_number = entry_name[position + 1..].parse().ok()?; + return Some(EntryKind::Partition(PartitionKey { + disk_number, + partition_number, + })); + } + + Some(EntryKind::Root(RootKey { + disk_number: entry_name.parse().ok()?, + })) +} + +fn block_object_path(scheme_name: &str, entry_name: &str) -> String { + format!( + "{UDISKS_BLOCK_PREFIX}{}", + stable_object_name(scheme_name, entry_name) + ) +} + +fn drive_object_path(scheme_name: &str, entry_name: &str) -> String { + format!( + "{UDISKS_DRIVE_PREFIX}{}", + stable_object_name(scheme_name, entry_name) + ) +} + +fn stable_object_name(scheme_name: &str, entry_name: &str) -> String { + format!( + "{}_{}", + encode_path_component(scheme_name), + encode_path_component(entry_name) + ) +} + +fn encode_path_component(component: &str) -> String { + let mut encoded = String::new(); + + for byte in component.bytes() { + if byte.is_ascii_alphanumeric() { + encoded.push(byte as char); + } else { + encoded.push('_'); + encoded.push(hex_char(byte >> 4)); + encoded.push(hex_char(byte & 0x0f)); + } + } + + if encoded.is_empty() { + encoded.push('_'); + encoded.push('0'); + encoded.push('0'); + } + + encoded +} + +fn hex_char(value: u8) -> char { + match value { + 0..=9 => (b'0' + value) as char, + 10..=15 => (b'a' + (value - 10)) as char, + _ => unreachable!("hex nibble out of range"), + } +} + +fn validate_udisks(list_names_output: &str) -> Result<(), String> { + let runtime = DiskRuntime::discover(); + println!( + "UDISKS_RUNTIME_DRIVE_SURFACES={}", + runtime.drive_paths.len() + ); + println!( + "UDISKS_RUNTIME_BLOCK_SURFACES={}", + runtime.block_paths.len() + ); + + let managed_objects_output = run_command( + DBUS_SEND, + &[ + "--system", + "--dest=org.freedesktop.UDisks2", + "--type=method_call", + "--print-reply", + UDISKS_ROOT_PATH, + "org.freedesktop.DBus.ObjectManager.GetManagedObjects", + ], + "dbus-send UDisks2 GetManagedObjects", + )?; + + let managed_object_paths = + managed_object_keys_with_prefix(&managed_objects_output, "/org/freedesktop/UDisks2/"); + let managed_block_paths = + managed_object_keys_with_prefix(&managed_objects_output, UDISKS_BLOCK_PREFIX); + let managed_drive_paths = + managed_object_keys_with_prefix(&managed_objects_output, UDISKS_DRIVE_PREFIX); + let manager_present = managed_object_paths.contains(UDISKS_MANAGER_PATH); + + if !manager_present && (!runtime.block_paths.is_empty() || !runtime.drive_paths.is_empty()) { + return Err(format!( + "UDisks2 GetManagedObjects did not include manager object {UDISKS_MANAGER_PATH} while runtime disk surfaces were present" + )); + } + + println!("UDISKS_MANAGED_OBJECTS=present"); + note_bus_name_registered(list_names_output, UDISKS_DESTINATION, "UDISKS_BUS_NAME"); + + let missing_block_paths = runtime + .block_paths + .difference(&managed_block_paths) + .cloned() + .collect::>(); + if !missing_block_paths.is_empty() { + return Err(format!( + "UDisks2 managed objects missed runtime-backed block devices: {}", + summarize_set(&missing_block_paths) + )); + } + + let unexpected_block_paths = managed_block_paths + .difference(&runtime.block_paths) + .cloned() + .collect::>(); + if !unexpected_block_paths.is_empty() { + return Err(format!( + "UDisks2 exposed block devices not backed by /scheme/disk.*: {}", + summarize_set(&unexpected_block_paths) + )); + } + + let missing_drive_paths = runtime + .drive_paths + .difference(&managed_drive_paths) + .cloned() + .collect::>(); + if !missing_drive_paths.is_empty() { + return Err(format!( + "UDisks2 managed objects missed runtime-backed drives: {}", + summarize_set(&missing_drive_paths) + )); + } + + let unexpected_drive_paths = managed_drive_paths + .difference(&runtime.drive_paths) + .cloned() + .collect::>(); + if !unexpected_drive_paths.is_empty() { + return Err(format!( + "UDisks2 exposed drives not backed by /scheme/disk.*: {}", + summarize_set(&unexpected_drive_paths) + )); + } + + println!("UDISKS_BLOCK_OBJECT_PATHS=validated"); + Ok(()) +} + fn run() -> Result<(), String> { parse_args(PROGRAM, USAGE, std::env::args()).map_err(|err| { if err.is_empty() { @@ -41,7 +538,11 @@ fn run() -> Result<(), String> { println!("=== Red Bear OS Phase 5 Networking Check ==="); require_path("/usr/bin/dbus-daemon")?; - require_path("/usr/bin/netctl")?; + require_path("/usr/bin/dbus-send")?; + let netctl_bin = require_one_path( + &["/usr/bin/redbear-netctl", "/usr/bin/netctl"], + "NETCTL_BIN", + )?; require_path("/usr/bin/redbear-wifictl")?; let info_output = Command::new("redbear-info") @@ -79,7 +580,7 @@ fn run() -> Result<(), String> { require_json_field(network, "wifi_connect_result")?; require_json_field(network, "wifi_disconnect_result")?; - let _ = Command::new("netctl").arg("status").status(); + let _ = Command::new(netctl_bin).arg("status").status(); let wifictl_output = Command::new("redbear-wifictl") .arg("--probe") @@ -103,6 +604,28 @@ fn run() -> Result<(), String> { return Err("redbear-wifictl --probe did not report capabilities=".to_string()); } + if Path::new("/run/dbus/system_bus_socket").exists() { + println!("DBUS_SYSTEM_BUS=present"); + } else { + println!("DBUS_SYSTEM_BUS=missing"); + } + + let list_names_output = run_command( + DBUS_SEND, + &[ + "--system", + "--dest=org.freedesktop.DBus", + "--type=method_call", + "--print-reply", + "/org/freedesktop/DBus", + "org.freedesktop.DBus.ListNames", + ], + "dbus-send org.freedesktop.DBus.ListNames", + )?; + + validate_upower(&list_names_output)?; + validate_udisks(&list_names_output)?; + let wifi_check_output = Command::new("redbear-phase5-wifi-check") .output() .map_err(|err| format!("failed to run redbear-phase5-wifi-check: {err}"))?; @@ -121,7 +644,7 @@ fn run() -> Result<(), String> { )); } let wifi_check_stdout = String::from_utf8_lossy(&wifi_check_output.stdout); - if wifi_check_stdout.contains("PASS: bounded Intel Wi-Fi runtime path exercised on target") { + if contains_phase5_wifi_success(&wifi_check_stdout) { println!("PHASE5_WIFI_CHECK=pass"); } else { return Err( @@ -143,11 +666,6 @@ fn run() -> Result<(), String> { } } - if Path::new("/run/dbus/system_bus_socket").exists() { - println!("DBUS_SYSTEM_BUS=present"); - } else { - println!("DBUS_SYSTEM_BUS=missing"); - } Ok(()) } @@ -173,4 +691,61 @@ mod tests { let value: Value = serde_json::json!({"wifi_control_state": "present"}); assert!(require_json_field(&value, "wifi_connect_result").is_err()); } + + #[test] + fn quoted_values_with_prefix_collects_expected_strings() { + let output = r#" + object path "/org/freedesktop/UPower/devices/line_power_AC" + string "/scheme/acpi/power/adapters/AC" + object path "/org/freedesktop/UDisks2/block_devices/disk_2e_nvme_0" + "#; + + let upower_paths = quoted_values_with_prefix(output, "/org/freedesktop/UPower/devices/"); + assert!(upower_paths.contains("/org/freedesktop/UPower/devices/line_power_AC")); + + let disk_paths = quoted_values_with_prefix(output, UDISKS_BLOCK_PREFIX); + assert!(disk_paths.contains("/org/freedesktop/UDisks2/block_devices/disk_2e_nvme_0")); + } + + #[test] + fn managed_object_keys_with_prefix_ignores_property_object_paths() { + let output = r#" + dict entry( + object path "/org/freedesktop/UDisks2/block_devices/disk_2e_nvme_0" + array [ + dict entry( + string "org.freedesktop.UDisks2.Block" + array [ + dict entry( + string "Drive" + variant object path "/org/freedesktop/UDisks2/drives/disk_2e_nvme_0" + ) + ] + ) + ] + ) + "#; + + let managed_blocks = managed_object_keys_with_prefix(output, UDISKS_BLOCK_PREFIX); + assert_eq!(managed_blocks.len(), 1); + assert!(managed_blocks.contains("/org/freedesktop/UDisks2/block_devices/disk_2e_nvme_0")); + + let managed_drives = managed_object_keys_with_prefix(output, UDISKS_DRIVE_PREFIX); + assert!(managed_drives.is_empty()); + } + + #[test] + fn stable_object_name_matches_udisks_inventory_encoding() { + assert_eq!(stable_object_name("disk.nvme", "0p1"), "disk_2envme_0p1"); + } + + #[test] + fn contains_phase5_wifi_success_accepts_current_and_legacy_markers() { + assert!(contains_phase5_wifi_success( + "PASS: bounded Intel Wi-Fi runtime path exercised inside target runtime" + )); + assert!(contains_phase5_wifi_success( + "PASS: bounded Intel Wi-Fi runtime path exercised on target" + )); + } } diff --git a/local/recipes/system/redbear-hwutils/source/src/bin/redbear-phase6-kde-check.rs b/local/recipes/system/redbear-hwutils/source/src/bin/redbear-phase6-kde-check.rs index bbc629ac..3a0acb7a 100644 --- a/local/recipes/system/redbear-hwutils/source/src/bin/redbear-phase6-kde-check.rs +++ b/local/recipes/system/redbear-hwutils/source/src/bin/redbear-phase6-kde-check.rs @@ -1,11 +1,30 @@ use std::path::Path; -use std::process::{self, Command}; +use std::process::{self, Command, Output}; use redbear_hwutils::parse_args; const PROGRAM: &str = "redbear-phase6-kde-check"; const USAGE: &str = "Usage: redbear-phase6-kde-check\n\nShow the installed Phase 6 KDE session surface inside the guest."; +const DBUS_ERROR_MARKERS: &[&str] = &[ + "org.freedesktop.DBus.Error", + "QDBusError", + "Could not connect to D-Bus", + "ServiceUnknown", + "No such interface", + "NoReply", + "UnknownMethod", +]; + +const SOLID_CONSUMER_MARKERS: &[&str] = &[ + "StorageAccess.", + "StorageDrive.", + "StorageVolume.", + "OpticalDrive.", + "AcAdapter.", + "Battery.", +]; + fn require_path(path: &str) -> Result<(), String> { if Path::new(path).exists() { println!("{path}"); @@ -15,6 +34,167 @@ fn require_path(path: &str) -> Result<(), String> { } } +fn command_output(command: &mut Command, description: &str) -> Result { + command + .output() + .map_err(|err| format!("failed to run {description}: {err}")) +} + +fn output_text(output: &Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + format!("{stdout}{stderr}") +} + +fn require_success(output: &Output, description: &str) -> Result<(), String> { + if output.status.success() { + Ok(()) + } else { + let text = output_text(output); + let detail = text.trim(); + if detail.is_empty() { + Err(format!( + "{description} exited with status {}", + output.status + )) + } else { + Err(format!( + "{description} exited with status {}: {}", + output.status, detail + )) + } + } +} + +fn contains_dbus_error(text: &str) -> bool { + DBUS_ERROR_MARKERS + .iter() + .any(|marker| text.contains(marker)) +} + +fn contains_solid_consumer_surface(text: &str) -> bool { + SOLID_CONSUMER_MARKERS + .iter() + .any(|marker| text.contains(marker)) +} + +fn require_dbus_free_output(output: &Output, description: &str) -> Result<(), String> { + let text = output_text(output); + if contains_dbus_error(&text) { + Err(format!( + "{description} reported a D-Bus error: {}", + text.trim() + )) + } else { + Ok(()) + } +} + +fn check_system_bus_consumers() -> Result<(), String> { + if !Path::new("/usr/bin/dbus-send").exists() { + println!("PHASE6_DBUS_SEND=missing"); + println!("PHASE6_UPOWER_ENUMERATE=skipped_missing_dbus_send"); + println!("PHASE6_UDISKS2_OBJECTS=skipped_missing_dbus_send"); + return Ok(()); + } + + println!("/usr/bin/dbus-send"); + + let names = command_output( + Command::new("dbus-send") + .arg("--system") + .arg("--dest=org.freedesktop.DBus") + .arg("--type=method_call") + .arg("--print-reply") + .arg("/org/freedesktop/DBus") + .arg("org.freedesktop.DBus.ListNames"), + "dbus-send ListNames", + )?; + require_success(&names, "dbus-send ListNames")?; + let names_text = output_text(&names); + let upower = command_output( + Command::new("dbus-send") + .arg("--system") + .arg("--dest=org.freedesktop.UPower") + .arg("--type=method_call") + .arg("--print-reply") + .arg("/org/freedesktop/UPower") + .arg("org.freedesktop.UPower.EnumerateDevices"), + "dbus-send UPower EnumerateDevices", + )?; + require_success(&upower, "dbus-send UPower EnumerateDevices")?; + require_dbus_free_output(&upower, "dbus-send UPower EnumerateDevices")?; + if names_text.contains("org.freedesktop.UPower") { + println!("PHASE6_UPOWER_BUS_NAME=present"); + } else { + println!("PHASE6_UPOWER_BUS_NAME=activated_lazily"); + } + println!("PHASE6_UPOWER_ENUMERATE=ok"); + + let udisks = command_output( + Command::new("dbus-send") + .arg("--system") + .arg("--dest=org.freedesktop.UDisks2") + .arg("--type=method_call") + .arg("--print-reply") + .arg("/org/freedesktop/UDisks2") + .arg("org.freedesktop.DBus.ObjectManager.GetManagedObjects"), + "dbus-send UDisks2 GetManagedObjects", + )?; + require_success(&udisks, "dbus-send UDisks2 GetManagedObjects")?; + require_dbus_free_output(&udisks, "dbus-send UDisks2 GetManagedObjects")?; + if names_text.contains("org.freedesktop.UDisks2") { + println!("PHASE6_UDISKS2_BUS_NAME=present"); + } else { + println!("PHASE6_UDISKS2_BUS_NAME=activated_lazily"); + } + println!("PHASE6_UDISKS2_OBJECTS=ok"); + + Ok(()) +} + +fn check_solid_runtime() -> Result<(), String> { + let tool = "/usr/bin/solid-hardware6"; + if !Path::new(tool).exists() { + println!("PHASE6_SOLID_RUNTIME=blocked_missing_tool"); + println!("PHASE6_SOLID_TODO=solid-hardware6_not_present_in_image"); + return Ok(()); + } + + println!("{tool}"); + + let output = command_output( + Command::new("solid-hardware6").arg("list").arg("details"), + "solid-hardware6 list details", + )?; + require_success(&output, "solid-hardware6 list details")?; + require_dbus_free_output(&output, "solid-hardware6 list details")?; + + let text = output_text(&output); + if contains_solid_consumer_surface(&text) { + println!("PHASE6_SOLID_RUNTIME=checked"); + } else { + println!("PHASE6_SOLID_RUNTIME=blocked_missing_storage_or_power_surface"); + println!("PHASE6_SOLID_TODO=solid-hardware6_did_not_expose_storage_or_power_surfaces"); + } + + Ok(()) +} + +fn check_redbear_info() -> Result<(), String> { + let output = command_output( + Command::new("redbear-info").arg("--json"), + "redbear-info --json", + )?; + if !output.stdout.is_empty() { + print!("{}", String::from_utf8_lossy(&output.stdout)); + } + if !output.stderr.is_empty() { + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + } + require_success(&output, "redbear-info --json") +} + fn run() -> Result<(), String> { parse_args(PROGRAM, USAGE, std::env::args()).map_err(|err| { if err.is_empty() { @@ -28,16 +208,10 @@ fn run() -> Result<(), String> { require_path("/usr/bin/kwin_wayland")?; require_path("/usr/bin/dbus-daemon")?; require_path("/usr/bin/seatd")?; + check_system_bus_consumers()?; + check_solid_runtime()?; - let status = Command::new("redbear-info") - .arg("--json") - .status() - .map_err(|err| format!("failed to run redbear-info --json: {err}"))?; - if status.success() { - Ok(()) - } else { - Err(format!("redbear-info exited with status {status}")) - } + check_redbear_info() } fn main() { @@ -46,3 +220,32 @@ fn main() { process::exit(1); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contains_dbus_error_detects_known_markers() { + assert!(contains_dbus_error( + "QDBusError(org.freedesktop.DBus.Error.ServiceUnknown, service missing)" + )); + } + + #[test] + fn contains_dbus_error_ignores_clean_output() { + assert!(!contains_dbus_error("method return time=0.0 sender=:1.2")); + } + + #[test] + fn contains_solid_consumer_surface_detects_storage_and_power_interfaces() { + assert!(contains_solid_consumer_surface( + "StorageDrive.removable = false\nBattery.percentage = 100" + )); + } + + #[test] + fn contains_solid_consumer_surface_rejects_unrelated_output() { + assert!(!contains_solid_consumer_surface("Processor.number = 8")); + } +} diff --git a/local/scripts/test-phase5-network-qemu.sh b/local/scripts/test-phase5-network-qemu.sh index 154349fb..6f63009a 100644 --- a/local/scripts/test-phase5-network-qemu.sh +++ b/local/scripts/test-phase5-network-qemu.sh @@ -84,12 +84,22 @@ expect "dbus-daemon" expect "virtio_net_present" expect "wifi_control_state=present" expect "wifi_connect_result=present" + expect "WIFICTL_INTERFACES=present" + expect "WIFICTL_CAPABILITIES=present" + expect "DBUS_SYSTEM_BUS=" + expect "UPOWER_BUS_NAME=present" + expect "UPOWER_RUNTIME_ADAPTERS=" + expect "UPOWER_RUNTIME_BATTERIES=" + expect "UPOWER_ENUMERATED_DEVICES=" + expect "UPOWER_NATIVE_PATHS=validated" + expect "UDISKS_BUS_NAME=present" + expect "UDISKS_RUNTIME_DRIVE_SURFACES=" + expect "UDISKS_RUNTIME_BLOCK_SURFACES=" + expect "UDISKS_MANAGED_OBJECTS=present" + expect "UDISKS_BLOCK_OBJECT_PATHS=validated" expect "PHASE5_WIFI_CHECK=pass" -expect "WIFICTL_INTERFACES=present" -expect "WIFICTL_CAPABILITIES=present" -expect "DBUS_SYSTEM_BUS=" -send "shutdown\r" -expect eof + send "shutdown\r" + expect eof EOF exit 0 fi diff --git a/local/scripts/test-phase6-kde-qemu.sh b/local/scripts/test-phase6-kde-qemu.sh index 0f4b0e40..5450565f 100644 --- a/local/scripts/test-phase6-kde-qemu.sh +++ b/local/scripts/test-phase6-kde-qemu.sh @@ -31,6 +31,37 @@ Boot or validate the Red Bear OS Phase 6 KDE session surface on redbear-kde. USAGE } +report_solid_recipe_blockers() { + local recipe="local/recipes/kde/kf6-solid/recipe.toml" + local blockers=0 + + if [[ ! -f "$recipe" ]]; then + echo "PHASE6_SOLID_RECIPE=missing:$recipe" + return 0 + fi + + echo "PHASE6_SOLID_RECIPE=$recipe" + + if grep -Fq -- '-DUSE_DBUS=OFF' "$recipe"; then + echo "PHASE6_SOLID_RECIPE_BLOCKER=USE_DBUS_off" + blockers=1 + fi + + if grep -Fq -- '-DBUILD_DEVICE_BACKEND_upower=OFF' "$recipe"; then + echo "PHASE6_SOLID_RECIPE_BLOCKER=upower_backend_off" + blockers=1 + fi + + if grep -Fq -- '-DBUILD_DEVICE_BACKEND_udisks2=OFF' "$recipe"; then + echo "PHASE6_SOLID_RECIPE_BLOCKER=udisks2_backend_off" + blockers=1 + fi + + if [[ "$blockers" -eq 0 ]]; then + echo "PHASE6_SOLID_RECIPE=ready_for_runtime_probe" + fi +} + check_mode=0 filtered_args=() for arg in "$@"; do @@ -69,6 +100,7 @@ if [[ ! -f "$extra" ]]; then fi if [[ "$check_mode" -eq 1 ]]; then + report_solid_recipe_blockers expect <