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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user