d-bus: redbear-udisks mount/unmount + notifications ActionInvoked (v3.3)

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).
This commit is contained in:
Red Bear OS Builder
2026-07-26 20:32:20 +09:00
parent fa286b2520
commit db5f289d46
10 changed files with 564 additions and 44 deletions
+19 -16
View File
@@ -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.
@@ -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"
@@ -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<'_>,
@@ -20,6 +20,47 @@ struct StatusNotifierWatcher {
hosts: Arc<Mutex<HashSet<String>>>,
}
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<String> {
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<Vec<String>> {
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<bool> {
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<dyn std::error::Error>> {
// 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<dyn std::error::Error>> {
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);
}
}
@@ -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"
@@ -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"
@@ -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<Vec<u8>> {
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<String, Value<'_>>,
) -> Result<String, zbus::fdo::Error> {
// UDisks2 fstype option: "empty" / "ext4" / "vfat" / ...
let fstype_hint = options
.get("fstype")
.and_then(|v| v.downcast_ref::<String>().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<String, Value<'_>>,
) -> 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")]
@@ -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<Mutex<MountState>>,
}
/// 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<Option<String>>,
pub child_pid: Mutex<Option<i32>>,
pub filesystem: Mutex<FilesystemKind>,
}
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())),
});
}
}
@@ -1,5 +1,6 @@
mod interfaces;
mod inventory;
mod mount;
use std::{
env,
@@ -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<bool> {
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<bool> {
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<String> {
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<u8> {
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());
}
}