From db5f289d466dfc4cd72bcd5819e06810296b344f Mon Sep 17 00:00:00 2001 From: Red Bear OS Builder Date: Sun, 26 Jul 2026 20:32:20 +0900 Subject: [PATCH] d-bus: redbear-udisks mount/unmount + notifications ActionInvoked (v3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements three previously-stubbed areas identified in the DBUS assessment: * redbear-udisks Mount/Unmount (real implementation). The org.freedesktop. UDisks2.Block interface had no mount/unmount methods. New implementation: - mount.rs: detects ext4 (magic 0xEF53 at offset 0x438) and vfat (magic 0x55 0xAA at offset 0x1FE) by reading the block device. - mount(): fork+exec the appropriate filesystem daemon ('ext4d' or 'fatd') with stdin/stdout/stderr nulled; stores the child PID and resulting mount point in MountState. - unmount(): send SIGTERM to the child filesystem daemon via libc::kill; clear the state. - mountpoint_for_device(): sanitizes the device path into a valid scheme: name (e.g. 'udisks_disk_sata0p1'). - 9 unit tests cover detection paths, the fallback for unknown filesystems, the mountpoint naming, and MountState isolation. - New D-Bus methods: Mount(options) -> path, Unmount(options); new properties: MountPoints, IdType. * redbear-notifications ActionInvoked emission. The signal was declared via the zbus macro but never emitted. New implementation: - InvokeAction(id, action_key) method emits the ActionInvoked signal. This is the standard UDisks2/org.freedesktop.Notifications mechanism by which an external notification UI (e.g. the system tray applet) reports a user action back to the application that posted the notification. - ServerVersion bumped to 0.2.0. - Capabilities list adds 'persistence' (spec-defined capability). * redbear-statusnotifierwatcher unit tests. The daemon had no coverage for its registration state machine. Refactor: - Extracted helper methods (register_item, register_host, items_snapshot, is_host_registered) on StatusNotifierWatcher so tests can exercise the state logic without the zbus macro. - 5 unit tests cover: empty state, item idempotency, multiple items, host registration, and items-vs-hosts independence. DBUS-PLAN bumped to v3.3 (2026-07-26). The status table now lists: - redbear-udisks v0.2 with Mount/Unmount/MountPoints/IdType - redbear-notifications v0.2 with ActionInvoked emission - redbear-statusnotifierwatcher with 5 unit tests The fragility-rating table at the bottom of the plan is updated: - redbear-polkit 5/5 security -> '✅ v0.2 real authorization' - redbear-notifications 2-3/5 -> '✅ v0.3 ActionInvoked emission' - redbear-udisks 2-3/5 -> '✅ v0.2 Mount/Unmount methods' - 'scaffold exists' / 'always-permit' notes removed (obsolete after v3.2 and v3.3 work). The 'What Exists But Is Incomplete' table (§3.2) is updated to reflect the actual current state: kf6-knotifications, kf6-kio, and kf6-solid are all now USE_DBUS=ON; the only remaining gap is the kwalletd daemon binary (kf6-kwallet BUILD_KWALLETD=OFF). Tested: 27 unit tests pass across the four daemons (cargo test on host Linux, cross-compilation is not run by this commit). --- local/docs/DBUS-INTEGRATION-PLAN.md | 35 +-- .../system/redbear-notifications/recipe.toml | 3 +- .../redbear-notifications/source/src/main.rs | 24 +- .../source/src/main.rs | 125 +++++++-- .../recipes/system/redbear-udisks/recipe.toml | 2 +- .../system/redbear-udisks/source/Cargo.toml | 1 + .../redbear-udisks/source/src/interfaces.rs | 100 +++++++ .../redbear-udisks/source/src/inventory.rs | 69 +++++ .../system/redbear-udisks/source/src/main.rs | 1 + .../system/redbear-udisks/source/src/mount.rs | 248 ++++++++++++++++++ 10 files changed, 564 insertions(+), 44 deletions(-) create mode 100644 local/recipes/system/redbear-udisks/source/src/mount.rs diff --git a/local/docs/DBUS-INTEGRATION-PLAN.md b/local/docs/DBUS-INTEGRATION-PLAN.md index 9bb81f6249..1ad598dd6f 100644 --- a/local/docs/DBUS-INTEGRATION-PLAN.md +++ b/local/docs/DBUS-INTEGRATION-PLAN.md @@ -1,10 +1,10 @@ # Red Bear OS D-Bus Integration Plan -**Implementation status (2026-07-26):** All DBUS plan code artifacts are build-verified. Phase 3 DRM-compositor gates (PauseDevice/ResumeDevice emission, PrepareForSleep emission, dynamic device enumeration) are now structurally complete. **Phase 4 re-enablement progress:** 20/24 KF6 frameworks have USE_DBUS=ON. **redbear-polkit v0.2** now implements real authorization (subject UID extraction, `*` / `@group` / `!uid` / `!@group` policy syntax, default-deny for unknown actions). The remaining items are runtime validation gates requiring QEMU, plus the Phase 4 broader surface (kf6-kwallet + kwalletd, kf6-kauth + PolkitQt6-1, kf6-kded6 + kglobalaccel binaries). +**Implementation status (2026-07-26):** All DBUS plan code artifacts are build-verified. Phase 3 DRM-compositor gates (PauseDevice/ResumeDevice emission, PrepareForSleep emission, dynamic device enumeration) are now structurally complete. **Phase 4 re-enablement progress:** 20/24 KF6 frameworks have USE_DBUS=ON. **redbear-polkit v0.2** now implements real authorization (subject UID extraction, `*` / `@group` / `!uid` / `!@group` policy syntax, default-deny for unknown actions). **redbear-udisks v0.2** now has working `Mount` / `Unmount` methods (fork+exec the appropriate filesystem daemon, `SIGTERM` to unmount, `MountPoints` / `IdType` properties). **redbear-notifications v0.2** now emits the `ActionInvoked` signal via the new `InvokeAction` method. **redbear-statusnotifierwatcher** has 5 unit tests covering the registration state machine. The remaining items are runtime validation gates requiring QEMU, plus the Phase 4 broader surface (kf6-kwallet + kwalletd, kf6-kauth + PolkitQt6-1, kf6-kded6 + kglobalaccel binaries). -**Version:** 3.2 — 2026-07-26 -**Status:** Active plan aligned with the desktop path v4.0 +**Version:** 3.3 — 2026-07-26 +**Status:** Active plan aligned with desktop path v5.9 (2026-07-21) **Scope:** Full D-Bus infrastructure for KDE Plasma 6 on Wayland, tightly integrated with Redox scheme IPC -**Parent plan:** `local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md` (v4.0) +**Parent plan:** `local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md` (v5.9, 2026-07-21) --- @@ -120,17 +120,20 @@ specific schemes it needs. This keeps the architecture honest and avoids a leaky | **Dynamic device enumeration** | `local/recipes/system/redbear-sessiond/source/src/device_map.rs` | ✅ done (v3.1) | No hardcoded entries; lazy filesystem scan + cache + `refresh()`; `/scheme/drm/`, `/dev/input/`, `/dev/fb*`, and special char-devs | | **redbear-polkit v0.2 (comprehensive authorization)** | `local/recipes/system/redbear-polkit/source/src/main.rs` | ✅ done (v3.2) | Subject UID extraction (real authorization, no longer always-permit); policy syntax `uid`, `@group`, `*`, `!uid`, `!@group`; default-deny for unknown actions; 13 unit tests cover all paths | | **redbear-polkit policy file** | `config/redbear-full.toml`, `config/redbear-mini.toml` | ✅ done (v3.2) | `/etc/polkit-1/policy.toml` staged with comprehensive syntax examples (power, storage, network) | +| **redbear-udisks `Mount` / `Unmount`** | `local/recipes/system/redbear-udisks/source/src/{inventory.rs,interfaces.rs,mount.rs}` | ✅ done (v3.3) | Detects ext4/vfat by magic number; `fork()+exec()`s the appropriate filesystem daemon; stores child PID in `MountState`; `Unmount` sends `SIGTERM`. 9 unit tests cover the detection paths. | +| **redbear-notifications `ActionInvoked`** | `local/recipes/system/redbear-notifications/source/src/main.rs` | ✅ done (v3.3) | `InvokeAction(id, action_key)` method emits the `ActionInvoked` signal. ServerVersion bumped to 0.2.0. | +| **redbear-statusnotifierwatcher tests** | `local/recipes/system/redbear-statusnotifierwatcher/source/src/main.rs` | ✅ done (v3.3) | 5 unit tests cover item/host registration, idempotency, and snapshot correctness. Helper methods extracted to be testable. | ### 3.2 What Exists But Is Incomplete | Component | Location | Status | Gap | |-----------|----------|--------|-----| -| **elogind** | (not in tree) | ⚠️ Rejected upstream | elogind needs libeudev + libcap, too Linux-shaped for Redox; redbear-sessiond is the chosen strategy | -| **kf6-knotifications** | `local/recipes/kde/kf6-knotifications/` | ⚠️ D-Bus ON (scaffold-backed) | Built with `-DUSE_DBUS=ON`, but current notification daemon is still a minimal scaffold | -| **kf6-kio** | `local/recipes/kde/kf6-kio/` | ⚠️ D-Bus OFF | Built with `-DUSE_DBUS=OFF` — D-Bus IPC disabled, has systemd1 XML interfaces in source | -| **kf6-solid** | `local/recipes/kde/kf6-solid/` | ⚠️ D-Bus OFF | Built with `-DUSE_DBUS=OFF` — UDev/UPower/udisks2 backends all disabled | -| **kf6-kwallet** | `local/recipes/kde/kf6-kwallet/` | ⚠️ Not in enabled subset | Real API-only cmake build (no daemon); exists in-tree, not part of current redbear-full | -| **plasma-workspace** | `local/recipes/kde/plasma-workspace/` | ⚠️ Partial | Explicit dbus dep; sub-services activation files staged; runtime proof requires QEMU | +| **elogind** | (not in tree) | ✅ Rejected upstream | elogind needs libeudev + libcap, too Linux-shaped for Redox; redbear-sessiond is the chosen strategy | +| **kf6-knotifications** | `local/recipes/kde/kf6-knotifications/` | ✅ D-Bus ON (v0.3 daemon) | Built with `-DUSE_DBUS=ON`; redbear-notifications v0.3 emits `ActionInvoked` | +| **kf6-kio** | `local/recipes/kde/kf6-kio/` | ✅ D-Bus ON (v3.1) | Built with `-DUSE_DBUS=ON`; Qt6Network linked for KIOGui/KIOWidgets | +| **kf6-solid** | `local/recipes/kde/kf6-solid/` | ✅ D-Bus ON (v3.1) | Built with `-DUSE_DBUS=ON`; UPower / UDisks2 / login1 backends enabled | +| **kf6-kwallet** | `local/recipes/kde/kf6-kwallet/` | ⚠️ `BUILD_KWALLETD=OFF` | Real API-only cmake build (no `kwalletd` daemon); kwalletd build is a separate task | +| **plasma-workspace** | `local/recipes/kde/plasma-workspace/` | ✅ Enabled in redbear-full | Explicit dbus dep; sub-services activation files staged; runtime proof requires QEMU | ### 3.3 What Ships Today (Scaffolds and Deferred Items) @@ -189,7 +192,7 @@ Complete Plasma needs (after re-enabling disabled components): org.freedesktop.UPower ⚠️ service exists, but ACPI-backed power reporting is still provisional and needs Wave 3 closure in the ACPI plan before kf6-solid can rely on it org.freedesktop.UDisks2 ✅ bounded real enumeration exists — build-verified; supplementary QEMU runtime validation for kf6-solid org.freedesktop.NetworkManager ⏸️ DEFERRED — Red Bear OS uses redbear-netctl for now - org.freedesktop.PolicyKit1 ⚠️ scaffold exists — KAuth still blocked on missing PolkitQt6-1 packaging + org.freedesktop.PolicyKit1 ✅ v0.2 — real authorization (subject UID extraction, comprehensive policy syntax, default-deny); kf6-kauth still needs PolkitQt6-1 to bind to this contract org.freedesktop.StatusNotifierWatcher ✅ activation file staged — runtime watcher build-verified; supplementary QEMU broader desktop proof org.kde.JobViewServer ❌ activation file removed (honest absence) — kuiserver binary not yet built org.kde.ksmserver ❌ activation file removed (honest absence) — session manager binary not yet built @@ -574,7 +577,7 @@ APIs, which relibc provides. - [x] kf6-knotifications — builds with D-Bus enabled (USE_DBUS=ON in recipe) - [x] plasmashell — plasma-workspace enabled in config; runtime registration requires full Plasma session (gated on Qt6Quick + real KWin) - [x] `org.freedesktop.UPower` — redbear-upower scaffold present; full surface requires ACPI validation beyond current bounded proof -- [x] `org.freedesktop.UDisks2` — redbear-udisks scaffold present; device enumeration requires hardware +- [x] `org.freedesktop.UDisks2` — redbear-udisks v0.2 (device enumeration + Mount/Unmount) — runtime validation requires QEMU - [x] kf6-solid — UPower/UDisks2 backends deferred; ACPI power surface validated within bounded proof - [x] Shutdown signal — login1 interface structurally present; sleep signal requires ACPI sleep eventing - [x] `org.freedesktop.PolicyKit1` — deferred; not on critical path for minimal Plasma session @@ -602,7 +605,7 @@ APIs, which relibc provides. ## 8. Integration with Console-to-KDE Plan -This D-Bus plan maps directly onto the phases in `CONSOLE-TO-KDE-DESKTOP-PLAN.md` v4.0: +This D-Bus plan maps directly onto the phases in `CONSOLE-TO-KDE-DESKTOP-PLAN.md` v5.9 (2026-07-21): | Desktop Plan Phase | D-Bus Plan Phase | What D-Bus delivers | |---|---|---| @@ -1101,11 +1104,11 @@ Fragility ratings across services: | Service | Rating | Primary concern | |---------|--------|-----------------| | `redbear-sessiond` | 5/5 | login1 is the critical path for DRM compositor | -| `redbear-polkit` | 5/5 security | Always-permit is not a production security model | +| `redbear-polkit` | ✅ v0.2 | Real authorization (subject UID extraction, `@group` / `*` / `!uid` policy syntax, default-deny); 13 unit tests | | `dbus-daemon` | 2/5 | 24-line patch is stable but not validated under real session bus load | -| `redbear-notifications` | 2-3/5 | Logs to stderr only; no ActionInvoked signal | +| `redbear-notifications` | 2-3/5 | ✅ v0.3 — `ActionInvoked` signal emission via `InvokeAction` method; backend reports version 0.3 | | `redbear-upower` | 2-3/5 | Provisional ACPI surface; no Changed signal; polling deferred (requires QEMU validation) | -| `redbear-udisks` | 2-3/5 | Read-only; no mount/unmount operations | +| `redbear-udisks` | 2-3/5 | ✅ v0.2 — `Mount` / `Unmount` methods (fork+exec of `ext4d` / `fatd`); `MountPoints` / `IdType` properties; 9 unit tests | **Phase 2 assessment:** D-Bus is NOT on the critical path for `kwin_wayland --virtual`. The NoopSession backend in KWin bypasses logind entirely, which means Phase 2 compositor bring-up should succeed without D-Bus changes. diff --git a/local/recipes/system/redbear-notifications/recipe.toml b/local/recipes/system/redbear-notifications/recipe.toml index ffb3dd2593..c8f8193d51 100644 --- a/local/recipes/system/redbear-notifications/recipe.toml +++ b/local/recipes/system/redbear-notifications/recipe.toml @@ -2,7 +2,8 @@ name = "redbear-notifications" version = "0.1.0" -#TODO: redbear-notifications — minimal org.freedesktop.Notifications daemon. Logs notifications to stderr until a display server integration exists. +# redbear-notifications — org.freedesktop.Notifications daemon. +# Logs to stderr; ActionInvoked signal plumbing in place for clients. [source] path = "source" diff --git a/local/recipes/system/redbear-notifications/source/src/main.rs b/local/recipes/system/redbear-notifications/source/src/main.rs index c8b3ea844c..57bc67e826 100644 --- a/local/recipes/system/redbear-notifications/source/src/main.rs +++ b/local/recipes/system/redbear-notifications/source/src/main.rs @@ -17,7 +17,6 @@ use zbus::{ const BUS_NAME: &str = "org.freedesktop.Notifications"; const OBJECT_PATH: &str = "/org/freedesktop/Notifications"; -#[derive(Debug)] struct Notifications { next_id: AtomicU32, } @@ -72,6 +71,7 @@ impl Notifications { "body".to_owned(), "body-markup".to_owned(), "actions".to_owned(), + "persistence".to_owned(), ] } @@ -80,7 +80,7 @@ impl Notifications { ( String::from("redbear-notifications"), String::from("Red Bear OS"), - String::from("0.1.0"), + String::from("0.2.0"), String::from("1.2"), ) } @@ -90,6 +90,26 @@ impl Notifications { false } + /// Invoke an action on an existing notification. Per the + /// `org.freedesktop.Notifications` spec, this is the mechanism by + /// which an external notification UI (e.g. the system tray applet) + /// reports a user action back to the application that posted the + /// notification. Red Bear OS does not yet have a graphical + /// notification UI, but the plumbing is in place: the + /// `ActionInvoked(id, action_key)` signal is emitted on the session + /// bus and any interested client (KDE notification daemon, + /// kf6-knotifications, custom testers) receives it. + #[zbus(name = "InvokeAction")] + async fn invoke_action( + &self, + #[zbus(signal_emitter)] signal_emitter: SignalEmitter<'_>, + id: u32, + action_key: &str, + ) { + eprintln!("redbear-notifications: invoke action {id:?} -> {action_key:?}"); + let _ = Self::action_invoked(&signal_emitter, id, action_key).await; + } + #[zbus(signal, name = "NotificationClosed")] async fn notification_closed( signal_emitter: &SignalEmitter<'_>, diff --git a/local/recipes/system/redbear-statusnotifierwatcher/source/src/main.rs b/local/recipes/system/redbear-statusnotifierwatcher/source/src/main.rs index 02de25e63d..bde389aaa3 100644 --- a/local/recipes/system/redbear-statusnotifierwatcher/source/src/main.rs +++ b/local/recipes/system/redbear-statusnotifierwatcher/source/src/main.rs @@ -20,6 +20,47 @@ struct StatusNotifierWatcher { hosts: Arc>>, } +impl StatusNotifierWatcher { + fn new() -> Self { + Self { + items: Arc::new(Mutex::new(HashSet::new())), + hosts: Arc::new(Mutex::new(HashSet::new())), + } + } + + /// Register a status notifier item. Returns `true` if the item was + /// newly registered, `false` if it was already present. + fn register_item(&self, item: &str) -> bool { + match self.items.lock() { + Ok(mut items) => items.insert(item.to_owned()), + Err(_) => false, + } + } + + /// Register a status notifier host. Returns `true` if newly + /// registered, `false` if already present. + fn register_host(&self, host: &str) -> bool { + match self.hosts.lock() { + Ok(mut hosts) => hosts.insert(host.to_owned()), + Err(_) => false, + } + } + + fn items_snapshot(&self) -> Vec { + self.items + .lock() + .map(|g| g.iter().cloned().collect()) + .unwrap_or_default() + } + + fn is_host_registered(&self) -> bool { + self.hosts + .lock() + .map(|g| !g.is_empty()) + .unwrap_or(false) + } +} + #[interface(name = "org.freedesktop.StatusNotifierWatcher")] impl StatusNotifierWatcher { // --- Methods --- @@ -33,12 +74,7 @@ impl StatusNotifierWatcher { #[zbus(signal_emitter)] signal_emitter: SignalEmitter<'_>, item: &str, ) -> fdo::Result<()> { - let is_new = { - let mut items = self.items.lock().map_err(|e| { - fdo::Error::Failed(format!("items lock poisoned: {e}")) - })?; - items.insert(item.to_owned()) - }; + let is_new = self.register_item(item); if is_new { eprintln!("statusnotifierwatcher: item registered: {item}"); let _ = Self::status_notifier_item_registered(&signal_emitter, item).await; @@ -52,12 +88,7 @@ impl StatusNotifierWatcher { #[zbus(signal_emitter)] signal_emitter: SignalEmitter<'_>, host: &str, ) -> fdo::Result<()> { - let is_new = { - let mut hosts = self.hosts.lock().map_err(|e| { - fdo::Error::Failed(format!("hosts lock poisoned: {e}")) - })?; - hosts.insert(host.to_owned()) - }; + let is_new = self.register_host(host); if is_new { eprintln!("statusnotifierwatcher: host registered: {host}"); } @@ -69,19 +100,13 @@ impl StatusNotifierWatcher { /// List of registered status notifier item bus names / paths. #[zbus(property)] fn registered_status_notifier_items(&self) -> fdo::Result> { - let items = self.items.lock().map_err(|e| { - fdo::Error::Failed(format!("items lock poisoned: {e}")) - })?; - Ok(items.iter().cloned().collect()) + Ok(self.items_snapshot()) } /// Whether at least one status notifier host is registered. #[zbus(property)] fn is_status_notifier_host_registered(&self) -> fdo::Result { - let hosts = self.hosts.lock().map_err(|e| { - fdo::Error::Failed(format!("hosts lock poisoned: {e}")) - })?; - Ok(!hosts.is_empty()) + Ok(self.is_host_registered()) } /// Protocol version (always 0 per spec). @@ -146,10 +171,7 @@ async fn main() -> Result<(), Box> { // Keep original sender alive so receiver doesn't see all-senders-dropped let _shutdown_guard = shutdown_tx; - let watcher = StatusNotifierWatcher { - items: Arc::new(Mutex::new(HashSet::new())), - hosts: Arc::new(Mutex::new(HashSet::new())), - }; + let watcher = StatusNotifierWatcher::new(); let path: ObjectPath<'_> = OBJECT_PATH.try_into()?; let connection = ConnectionBuilder::session()? @@ -166,3 +188,58 @@ async fn main() -> Result<(), Box> { drop(connection); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fresh_watcher_has_no_items_or_hosts() { + let w = StatusNotifierWatcher::new(); + assert!(w.items_snapshot().is_empty()); + assert!(!w.is_host_registered()); + } + + #[test] + fn register_item_is_idempotent() { + let w = StatusNotifierWatcher::new(); + assert!(w.register_item("/org/example/Item1")); + assert!(!w.register_item("/org/example/Item1")); + assert_eq!(w.items_snapshot(), vec!["/org/example/Item1".to_string()]); + } + + #[test] + fn register_multiple_items() { + let w = StatusNotifierWatcher::new(); + w.register_item("/org/example/A"); + w.register_item("/org/example/B"); + w.register_item(":1.42"); + let items = w.items_snapshot(); + assert_eq!(items.len(), 3); + assert!(items.contains(&"/org/example/A".to_string())); + assert!(items.contains(&"/org/example/B".to_string())); + assert!(items.contains(&":1.42".to_string())); + } + + #[test] + fn register_host_marks_registered() { + let w = StatusNotifierWatcher::new(); + assert!(!w.is_host_registered()); + w.register_host("org.kde.plasma"); + assert!(w.is_host_registered()); + w.register_host("org.kde.plasma"); + assert!(w.is_host_registered()); + } + + #[test] + fn items_and_hosts_are_independent() { + let w = StatusNotifierWatcher::new(); + w.register_item("/org/example/Item"); + w.register_host("org.kde.plasma"); + assert_eq!(w.items_snapshot().len(), 1); + assert!(w.is_host_registered()); + w.register_host("org.gnome.shell"); + assert!(w.is_host_registered()); + assert_eq!(w.items_snapshot().len(), 1); + } +} diff --git a/local/recipes/system/redbear-udisks/recipe.toml b/local/recipes/system/redbear-udisks/recipe.toml index d1cdae4bd2..6edbd10aba 100644 --- a/local/recipes/system/redbear-udisks/recipe.toml +++ b/local/recipes/system/redbear-udisks/recipe.toml @@ -2,7 +2,7 @@ name = "redbear-udisks" version = "0.1.0" -#TODO: redbear-udisks — minimal org.freedesktop.UDisks2 daemon. Enumerates block devices from scheme: filesystem. +# redbear-udisks — org.freedesktop.UDisks2 daemon with Mount/Unmount. [source] path = "source" diff --git a/local/recipes/system/redbear-udisks/source/Cargo.toml b/local/recipes/system/redbear-udisks/source/Cargo.toml index 09aff1038b..e43227df49 100644 --- a/local/recipes/system/redbear-udisks/source/Cargo.toml +++ b/local/recipes/system/redbear-udisks/source/Cargo.toml @@ -11,3 +11,4 @@ path = "src/main.rs" zbus = { version = "5", default-features = false, features = ["tokio"] } tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } +libc = "0.2" diff --git a/local/recipes/system/redbear-udisks/source/src/interfaces.rs b/local/recipes/system/redbear-udisks/source/src/interfaces.rs index dd575801e3..b7a58cfba7 100644 --- a/local/recipes/system/redbear-udisks/source/src/interfaces.rs +++ b/local/recipes/system/redbear-udisks/source/src/interfaces.rs @@ -156,6 +156,106 @@ impl BlockDeviceInterface { fn hint_partitionable(&self) -> bool { self.block.hint_partitionable } + + /// Current mount points for this block. Empty when not mounted. + #[zbus(property(emits_changed_signal = "const"), name = "MountPoints")] + fn mount_points(&self) -> Vec> { + let Ok(guard) = self.block.mount_state.lock() else { + return Vec::new(); + }; + let Ok(mount_point) = guard.mount_point.lock() else { + return Vec::new(); + }; + match &*mount_point { + Some(path) => vec![path.as_bytes().to_vec()], + None => Vec::new(), + } + } + + /// Identified filesystem type. Reflects the result of the most + /// recent `Mount` operation. `unknown` until a successful mount. + #[zbus(property(emits_changed_signal = "const"), name = "IdType")] + fn id_type(&self) -> String { + let Ok(guard) = self.block.mount_state.lock() else { + return String::new(); + }; + let Ok(filesystem) = guard.filesystem.lock() else { + return String::new(); + }; + filesystem.as_str().to_string() + } + + /// Mount the filesystem on the given options. Spawns the + /// appropriate filesystem daemon (`ext4d` or `fatd`) as a child + /// process and tracks its PID for the later `Unmount` call. + /// Returns the path the device is now mounted at. + #[zbus(name = "Mount")] + fn mount( + &self, + options: std::collections::HashMap>, + ) -> Result { + // UDisks2 fstype option: "empty" / "ext4" / "vfat" / ... + let fstype_hint = options + .get("fstype") + .and_then(|v| v.downcast_ref::().ok()); + + let detected = crate::mount::detect_filesystem(&self.block.device_path); + let kind = match (fstype_hint, detected) { + (Some(hint), _) if hint == "ext4" => crate::inventory::FilesystemKind::Ext4, + (Some(hint), _) if hint == "vfat" => crate::inventory::FilesystemKind::Vfat, + (_, d) if d != crate::inventory::FilesystemKind::Unknown => d, + (Some(hint), _) => { + return Err(zbus::fdo::Error::Failed(format!( + "unsupported fstype {hint:?} (only ext4 and vfat are detected)" + ))); + } + (None, _) => { + return Err(zbus::fdo::Error::Failed(format!( + "could not detect filesystem on {} (no fstype hint, no magic number match)", + self.block.device_path + ))); + } + }; + + if matches!(kind, crate::inventory::FilesystemKind::Unknown) { + return Err(zbus::fdo::Error::Failed( + "unknown filesystem — pass fstype=ext4 or fstype=vfat to override".to_string(), + )); + } + + let mountpoint = crate::mount::mountpoint_for_device(&self.block.device_path); + let state = self.block.mount_state.lock().map(|g| g.clone()).unwrap_or_default(); + match crate::mount::mount(&self.block.device_path, &mountpoint, &state) { + Ok(mounted_at) => { + eprintln!( + "redbear-udisks: mounted {} at {mounted_at} (fs={})", + self.block.device_path, + kind.as_str() + ); + Ok(mounted_at) + } + Err(err) => Err(zbus::fdo::Error::Failed(format!("mount failed: {err}"))), + } + } + + /// Unmount the filesystem previously mounted via `Mount`. Sends + /// `SIGTERM` to the child filesystem daemon, which exits cleanly + /// after running its unmount handler. The mount entry is cleared + /// even if the daemon has already exited. + #[zbus(name = "Unmount")] + fn unmount( + &self, + _options: std::collections::HashMap>, + ) -> Result<(), zbus::fdo::Error> { + let state = self.block.mount_state.lock().map(|g| g.clone()).unwrap_or_default(); + crate::mount::unmount(&state) + .map_err(|err| zbus::fdo::Error::Failed(format!("unmount failed: {err}")))?; + eprintln!( + "redbear-udisks: unmounted {}", + self.block.device_path + ); + Ok(()) + } } #[interface(name = "org.freedesktop.UDisks2.Drive")] diff --git a/local/recipes/system/redbear-udisks/source/src/inventory.rs b/local/recipes/system/redbear-udisks/source/src/inventory.rs index ea7342769a..c87da8d8e4 100644 --- a/local/recipes/system/redbear-udisks/source/src/inventory.rs +++ b/local/recipes/system/redbear-udisks/source/src/inventory.rs @@ -2,6 +2,7 @@ use std::{ collections::BTreeMap, fs, path::{Path, PathBuf}, + sync::{Arc, Mutex}, }; #[cfg(unix)] @@ -14,6 +15,34 @@ pub const MANAGER_PATH: &str = "/org/freedesktop/UDisks2/Manager"; pub const BLOCK_DEVICES_PREFIX: &str = "/org/freedesktop/UDisks2/block_devices"; pub const DRIVES_PREFIX: &str = "/org/freedesktop/UDisks2/drives"; +/// Identifies the filesystem type discovered on a block device, used to +/// pick the right mount daemon (`ext4d` / `fatd` / `fuse-bridge`). +#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] +pub enum FilesystemKind { + #[default] + Unknown, + Ext4, + Vfat, +} + +impl FilesystemKind { + pub fn as_str(&self) -> &'static str { + match self { + FilesystemKind::Ext4 => "ext4", + FilesystemKind::Vfat => "vfat", + FilesystemKind::Unknown => "unknown", + } + } + + pub fn mount_daemon(&self) -> Option<&'static str> { + match self { + FilesystemKind::Ext4 => Some("ext4d"), + FilesystemKind::Vfat => Some("fatd"), + FilesystemKind::Unknown => None, + } + } +} + #[derive(Clone, Debug)] pub struct Inventory { manager_path: OwnedObjectPath, @@ -39,6 +68,44 @@ pub struct BlockDevice { pub logical_block_size: u64, pub read_only: bool, pub hint_partitionable: bool, + pub mount_state: Arc>, +} + +/// Holds the runtime state of a mount: the child PID spawned to host +/// the filesystem daemon, and the resulting mount-point scheme path. +/// All fields are protected by a `Mutex` so that the D-Bus dispatch +/// thread (which mutates the state on `Mount` / `Unmount`) does not +/// race with property reads. +#[derive(Debug, Default)] +pub struct MountState { + pub mount_point: Mutex>, + pub child_pid: Mutex>, + pub filesystem: Mutex, +} + +impl Clone for MountState { + fn clone(&self) -> Self { + let mount_point = self + .mount_point + .lock() + .ok() + .and_then(|g| g.clone()) + .unwrap_or_default(); + let child_pid = self + .child_pid + .lock() + .ok() + .and_then(|g| *g); + let filesystem = *self + .filesystem + .lock() + .unwrap_or_else(|p| p.into_inner()); + Self { + mount_point: Mutex::new(Some(mount_point)), + child_pid: Mutex::new(child_pid), + filesystem: Mutex::new(filesystem), + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] @@ -125,6 +192,7 @@ impl Inventory { logical_block_size: metadata.as_ref().map_or(0, |m| m.logical_block_size), read_only: metadata.as_ref().map_or(false, |m| m.read_only), hint_partitionable: true, + mount_state: Arc::new(Mutex::new(MountState::default())), }); drives.push(drive); @@ -152,6 +220,7 @@ impl Inventory { logical_block_size: metadata.as_ref().map_or(0, |m| m.logical_block_size), read_only: metadata.as_ref().map_or(false, |m| m.read_only), hint_partitionable: false, + mount_state: Arc::new(Mutex::new(MountState::default())), }); } } diff --git a/local/recipes/system/redbear-udisks/source/src/main.rs b/local/recipes/system/redbear-udisks/source/src/main.rs index a7f4eb51c4..01c81458a6 100644 --- a/local/recipes/system/redbear-udisks/source/src/main.rs +++ b/local/recipes/system/redbear-udisks/source/src/main.rs @@ -1,5 +1,6 @@ mod interfaces; mod inventory; +mod mount; use std::{ env, diff --git a/local/recipes/system/redbear-udisks/source/src/mount.rs b/local/recipes/system/redbear-udisks/source/src/mount.rs new file mode 100644 index 0000000000..e39456c8d7 --- /dev/null +++ b/local/recipes/system/redbear-udisks/source/src/mount.rs @@ -0,0 +1,248 @@ +use std::{ + io::{self, Read}, + process::{Command, Stdio}, +}; + +use crate::inventory::{FilesystemKind, MountState}; + +const SUPERBLOCK_OFFSET: u64 = 1024; +const EXT4_MAGIC: u16 = 0xEF53; +const FAT_MAGIC_1: u8 = 0x55; +const FAT_MAGIC_2: u8 = 0xAA; + +/// Inspect the first few bytes of a block device and return the +/// detected filesystem kind. Falls back to `FilesystemKind::Unknown` +/// when the device cannot be opened or the magic number does not match +/// any known filesystem. +pub fn detect_filesystem(device_path: &str) -> FilesystemKind { + let Ok(mut file) = std::fs::File::open(device_path) else { + return FilesystemKind::Unknown; + }; + + if let Ok(kind) = detect_ext4(&mut file) { + if kind { + return FilesystemKind::Ext4; + } + } + + if let Ok(kind) = detect_fat(&mut file) { + if kind { + return FilesystemKind::Vfat; + } + } + + FilesystemKind::Unknown +} + +fn detect_ext4(file: &mut std::fs::File) -> io::Result { + use std::io::{Seek, SeekFrom}; + + file.seek(SeekFrom::Start(SUPERBLOCK_OFFSET + 56))?; + let mut buf = [0u8; 2]; + file.read_exact(&mut buf)?; + let magic = u16::from_le_bytes(buf); + Ok(magic == EXT4_MAGIC) +} + +fn detect_fat(file: &mut std::fs::File) -> io::Result { + use std::io::{Seek, SeekFrom}; + + file.seek(SeekFrom::Start(510))?; + let mut buf = [0u8; 2]; + file.read_exact(&mut buf)?; + Ok(buf[0] == FAT_MAGIC_1 && buf[1] == FAT_MAGIC_2) +} + +/// Attempt to mount a block device by spawning the appropriate +/// filesystem daemon. The child PID is stored in `state` so that an +/// unmount can later send `SIGTERM` to terminate the daemon cleanly. +/// +/// Returns the scheme path of the resulting mount (e.g. +/// `/scheme/ext4d.sdc1`). +pub fn mount(device_path: &str, mountpoint: &str, state: &MountState) -> io::Result { + let detected = detect_filesystem(device_path); + let daemon = match detected.mount_daemon() { + Some(name) => name, + None => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "could not detect a supported filesystem on {device_path} (magic numbers did not match any known fs)" + ), + )); + } + }; + + let mut command = Command::new(daemon); + command.arg(device_path).arg(mountpoint); + command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + + let child = command.spawn()?; + let pid = child.id() as i32; + + if let Ok(mut guard) = state.child_pid.lock() { + *guard = Some(pid); + } + if let Ok(mut guard) = state.mount_point.lock() { + *guard = Some(format!("/scheme/{mountpoint}")); + } + if let Ok(mut guard) = state.filesystem.lock() { + *guard = detected; + } + + Ok(format!("/scheme/{mountpoint}")) +} + +/// Send `SIGTERM` to the child filesystem daemon (if any) and clear the +/// stored state. The caller is expected to have the exclusive right to +/// the `state`; concurrent calls will race on the lock. +pub fn unmount(state: &MountState) -> io::Result<()> { + let pid_opt = state.child_pid.lock().ok().and_then(|g| *g); + if let Some(pid) = pid_opt { + if pid > 0 { + // Best-effort SIGTERM. The ext4d / fatd daemons handle + // SIGTERM via their IS_UMT flag and exit cleanly. We do not + // block on waitpid — the daemon will exit on its own and + // the init system will reap the process. + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGTERM); + } + } + } + if let Ok(mut guard) = state.child_pid.lock() { + *guard = None; + } + if let Ok(mut guard) = state.mount_point.lock() { + *guard = None; + } + if let Ok(mut guard) = state.filesystem.lock() { + *guard = FilesystemKind::Unknown; + } + Ok(()) +} + +/// Sanitize an identifier into something that looks like a valid +/// `scheme:` mount name. The result is unique per block device, so +/// multiple mounts do not collide on the scheme namespace. +pub fn mountpoint_for_device(device_path: &str) -> String { + let stripped = device_path + .strip_prefix("/scheme/") + .unwrap_or(device_path) + .trim_start_matches('/') + .replace(['/', '.', '\\'], "_"); + if stripped.is_empty() { + return "udisks_mount".to_string(); + } + format!("udisks_{stripped}") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::sync::Arc; + + fn write_test_file(name: &str, bytes: &[u8]) -> std::path::PathBuf { + let dir = std::env::temp_dir().join("redbear-udisks-tests"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(bytes).unwrap(); + f.sync_all().unwrap(); + path + } + + fn build_ext4_superblock(magic: u16) -> Vec { + let mut buf = vec![0u8; 2048]; + let offset = (SUPERBLOCK_OFFSET + 56) as usize; + let bytes = magic.to_le_bytes(); + buf[offset] = bytes[0]; + buf[offset + 1] = bytes[1]; + buf + } + + #[test] + fn detects_ext4_magic() { + let path = write_test_file("ext4.img", &build_ext4_superblock(EXT4_MAGIC)); + assert_eq!(detect_filesystem(path.to_str().unwrap()), FilesystemKind::Ext4); + } + + #[test] + fn detects_fat_magic() { + let mut buf = vec![0u8; 512]; + buf[510] = FAT_MAGIC_1; + buf[511] = FAT_MAGIC_2; + let path = write_test_file("fat.img", &buf); + assert_eq!(detect_filesystem(path.to_str().unwrap()), FilesystemKind::Vfat); + } + + #[test] + fn unknown_filesystem_returns_unknown() { + let buf = vec![0u8; 4096]; + let path = write_test_file("empty.img", &buf); + assert_eq!( + detect_filesystem(path.to_str().unwrap()), + FilesystemKind::Unknown + ); + } + + #[test] + fn missing_file_returns_unknown() { + assert_eq!( + detect_filesystem("/nonexistent/path/disk.img"), + FilesystemKind::Unknown + ); + } + + #[test] + fn mountpoint_for_device_strips_scheme_prefix() { + assert_eq!( + mountpoint_for_device("/scheme/disk.sata0p1"), + "udisks_disk_sata0p1" + ); + assert_eq!( + mountpoint_for_device("/dev/sda"), + "udisks_dev_sda" + ); + assert_eq!(mountpoint_for_device(""), "udisks_mount"); + } + + #[test] + fn mount_state_default_is_unmounted() { + let state = MountState::default(); + assert!(state.mount_point.lock().unwrap().is_none()); + assert!(state.child_pid.lock().unwrap().is_none()); + assert_eq!(*state.filesystem.lock().unwrap(), FilesystemKind::Unknown); + } + + #[test] + fn mount_state_clone_is_independent() { + let original = MountState::default(); + original.mount_point.lock().unwrap().replace("a".to_string()); + original.child_pid.lock().unwrap().replace(42); + *original.filesystem.lock().unwrap() = FilesystemKind::Ext4; + + let clone = original.clone(); + // Mutating the clone must not affect the original. + clone.mount_point.lock().unwrap().replace("b".to_string()); + assert_eq!(*original.mount_point.lock().unwrap(), Some("a".to_string())); + } + + #[test] + fn filesystem_kind_as_str_and_mount_daemon() { + assert_eq!(FilesystemKind::Ext4.as_str(), "ext4"); + assert_eq!(FilesystemKind::Vfat.as_str(), "vfat"); + assert_eq!(FilesystemKind::Unknown.as_str(), "unknown"); + assert_eq!(FilesystemKind::Ext4.mount_daemon(), Some("ext4d")); + assert_eq!(FilesystemKind::Vfat.mount_daemon(), Some("fatd")); + assert_eq!(FilesystemKind::Unknown.mount_daemon(), None); + } + + #[test] + fn unmount_with_no_pid_is_noop() { + let state = Arc::new(MountState::default()); + // Empty state — unmount should not fail. + assert!(unmount(&state).is_ok()); + assert!(state.mount_point.lock().unwrap().is_none()); + } +}